瀏覽代碼

Merge pull request #2028 from causefx/v2-develop

V2 develop
causefx 6 月之前
父節點
當前提交
3de321a4b9
共有 45 個文件被更改,包括 4526 次插入477 次删除
  1. 2 1
      .gitignore
  2. 71 18
      api/classes/organizr.class.php
  3. 62 1
      api/config/default.php
  4. 4 0
      api/functions.php
  5. 8 0
      api/functions/option-functions.php
  6. 13 0
      api/functions/organizr-functions.php
  7. 801 0
      api/homepage/embyLiveTVTracker.php
  8. 1881 0
      api/homepage/jellystat.php
  9. 123 48
      api/homepage/pihole.php
  10. 1 1
      api/homepage/plex.php
  11. 117 0
      api/homepage/prompage.php
  12. 4 2
      api/homepage/radarr.php
  13. 3 0
      api/homepage/sonarr.php
  14. 5 4
      api/pages/settings-settings-logs.php
  15. 2 2
      api/plugins/chat/plugin.php
  16. 1 1
      api/plugins/invites/plugin.php
  17. 13 0
      api/v2/index.php
  18. 32 0
      api/v2/routes/connectionTester.php
  19. 43 0
      api/v2/routes/homepage.php
  20. 1 1
      api/v2/routes/plugins.php
  21. 3 0
      css/organizr.css
  22. 0 0
      css/organizr.min.css
  23. 139 0
      debug_jellystat_metadata.php
  24. 41 1
      js/custom.js
  25. 0 0
      js/custom.min.js
  26. 0 0
      js/custom.min.js.bak
  27. 282 68
      js/functions.js
  28. 511 302
      js/langpack/ko[Korean].json
  29. 4 0
      js/version.json
  30. 二進制
      plugins/images/homepage/embyLiveTVTracker.png
  31. 二進制
      plugins/images/homepage/jellystat.png
  32. 二進制
      plugins/images/homepage/userWatchStats.png
  33. 二進制
      plugins/images/tabs/amule.png
  34. 二進制
      plugins/images/tabs/backrest.png
  35. 二進制
      plugins/images/tabs/copyparty.png
  36. 二進制
      plugins/images/tabs/flaresolverr.png
  37. 二進制
      plugins/images/tabs/prompage.png
  38. 二進制
      plugins/images/tabs/rustdesk.png
  39. 二進制
      plugins/images/tabs/trilium.png
  40. 二進制
      plugins/images/tabs/zipline.png
  41. 132 0
      poster_updates.js
  42. 65 27
      scripts/linux-update.sh
  43. 86 0
      server.log
  44. 39 0
      test_debug.php
  45. 37 0
      test_jellystat_api.html

+ 2 - 1
.gitignore

@@ -190,4 +190,5 @@ api/pages/custom/*.php
 /plugins/images/cache/tautulli-movie.svg
 /plugins/images/cache/tautulli-windows.svg
 /plugins/images/cache/tautulli-samsung.svg
-/plugins/images/cache/tautulli-chrome.svg
+/plugins/images/cache/tautulli-chrome.svg
+.vscode

+ 71 - 18
api/classes/organizr.class.php

@@ -33,6 +33,7 @@ class Organizr
 	use DelugeHomepageItem;
 	use DonateHomepageItem;
 	use EmbyHomepageItem;
+	use EmbyLiveTVTrackerHomepageItem;
 	use HealthChecksHomepageItem;
 	use HTMLHomepageItem;
 	use ICalHomepageItem;
@@ -65,10 +66,13 @@ class Organizr
 	use WeatherHomepageItem;
 	use uTorrentHomepageItem;
 	use UptimeKumaHomepageItem;
+	use JellyStatHomepageItem;
+	use PromPageHomepageItem;
+
 
 	// ===================================
 	// Organizr Version
-	public $version = '2.1.2490';
+	public $version = '2.1.3180';
 	// ===================================
 	// Quick php Version check
 	public $minimumPHP = '7.4';
@@ -92,6 +96,7 @@ class Organizr
 	public $paths;
 	public $checkForUpdates;
 	public $groupOptions;
+	public $userOptions;
 	public $warnings;
 	public $errors;
 	public bool $loggerSetup = false;
@@ -756,7 +761,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:
@@ -2378,7 +2383,7 @@ class Organizr
 				$this->settingsOption('select', 'authType', ['id' => 'authSelect', 'label' => 'Authentication Type', 'value' => $this->config['authType'], 'options' => $this->getAuthTypes()]),
 				$this->settingsOption('select', 'authBackend', ['id' => 'authBackendSelect', 'label' => 'Authentication Backend', 'class' => 'backendAuth switchAuth', 'value' => $this->config['authBackend'], 'options' => $this->getAuthBackends()]),
 				$this->settingsOption('token', 'plexToken', ['class' => 'plexAuth switchAuth']),
-				$this->settingsOption('button', '', ['class' => 'getPlexTokenAuth plexAuth switchAuth', 'label' => 'Get Plex Token', 'icon' => 'fa fa-ticket', 'text' => 'Retrieve', 'attr' => 'onclick="PlexOAuth(oAuthSuccess,oAuthError, null, \'#settings-main-form [name=plexToken]\')"']),
+				$this->settingsOption('button', '', ['class' => 'getPlexTokenAuth plexAuth switchAuth', 'label' => 'Get Plex Token', 'icon' => 'fa fa-ticket', 'text' => 'Retrieve', 'attr' => 'onclick="PlexOAuth(oAuthSuccess,oAuthError, oAuthMaxRetry, null, null, \'#settings-main-form [name=plexToken]\')"']),
 				$this->settingsOption('password-alt', 'plexID', ['class' => 'plexAuth switchAuth', 'label' => 'Plex Machine', 'placeholder' => 'Use Get Plex Machine Button']),
 				$this->settingsOption('button', '', ['class' => 'getPlexMachineAuth plexAuth switchAuth', 'label' => 'Get Plex Machine', 'icon' => 'fa fa-id-badge', 'text' => 'Retrieve', 'attr' => 'onclick="showPlexMachineForm(\'#settings-main-form [name=plexID]\')"']),
 				$this->settingsOption('input', 'plexAdmin', ['label' => 'Plex Admin Username or Email', 'class' => 'plexAuth switchAuth', 'placeholder' => 'Admin username for Plex']),
@@ -2458,6 +2463,8 @@ class Organizr
 				$this->settingsOption('switch', 'enableLocalAddressForward', ['label' => 'Enable Local Address Forward', 'help' => 'Enables the local address forward if on local address and accessed from WAN Domain']),
 				$this->settingsOption('switch', 'disableRecoverPass', ['label' => 'Disable Recover Password', 'help' => 'Disables recover password area']),
 				$this->settingsOption('input', 'customForgotPassText', ['label' => 'Custom Recover Password Text', 'help' => 'Text or HTML for recovery password section']),
+				$this->settingsOption('switch', 'bypassLoginForLocal', ['label' => 'Bypass Login For Local Access', 'help' => 'Disables login and logs user in with default User Id']),
+				$this->settingsOption('orguser', 'localLoginUserId', ['label' => 'Local User Id', 'help' => 'User Id to login the user when bypassing login']),
 			],
 			'Auth Proxy' => [
 				$this->settingsOption('switch', 'authProxyEnabled', ['label' => 'Auth Proxy', 'help' => 'Enable option to set Auth Proxy Header Login']),
@@ -2569,7 +2576,7 @@ class Organizr
 			],
 			'Plex' => [
 				$this->settingsOption('token', 'plexToken'),
-				$this->settingsOption('button', '', ['label' => 'Get Plex Token', 'icon' => 'fa fa-ticket', 'text' => 'Retrieve', 'attr' => 'onclick="PlexOAuth(oAuthSuccess,oAuthError, null, \'#sso-form [name=plexToken]\')"']),
+				$this->settingsOption('button', '', ['label' => 'Get Plex Token', 'icon' => 'fa fa-ticket', 'text' => 'Retrieve', 'attr' => 'onclick="PlexOAuth(oAuthSuccess,oAuthError, oAuthMaxRetry, null, null, \'#sso-form [name=plexToken]\')"']),
 				$this->settingsOption('password-alt', 'plexID', ['label' => 'Plex Machine']),
 				$this->settingsOption('button', '', ['label' => 'Get Plex Machine', 'icon' => 'fa fa-id-badge', 'text' => 'Retrieve', 'attr' => 'onclick="showPlexMachineForm(\'#sso-form [name=plexID]\')"']),
 				$this->settingsOption('input', 'plexAdmin', ['label' => 'Plex Admin Username or Email']),
@@ -3673,6 +3680,19 @@ class Organizr
 
 	public function login($array)
 	{
+		// Bypass Check
+		$bypassLogin = $this->config['bypassLoginForLocal'] && $this->config['localLoginUserId'] && $this->isLocal() == true;
+		if(gettype($array) == 'array'){
+			if(key_exists('bypass', $array)){
+				$bypassLogin = false;
+			}
+			if(key_exists('username', $array)){
+				$bypassLogin = false;
+			}
+			if(key_exists('oAuth', $array)){
+				$bypassLogin = false;
+			}
+		}
 		// Grab username, Password & other optional items from api call
 		$username = $array['username'] ?? null;
 		$password = $array['password'] ?? null;
@@ -3722,7 +3742,7 @@ class Organizr
 			}
 		}
 		// Check if Login method was an oAuth login
-		if (!$oAuth) {
+		if (!$oAuth && !$bypassLogin) {
 			$result = $this->getUserByUsernameAndEmail($username, $username);
 			$result['password'] = $result['password'] ?? '';
 			// Switch AuthType - internal - external - both
@@ -3748,6 +3768,10 @@ class Organizr
 					}
 			}
 			$authSuccess = ($authProxy) ? $addEmailToAuthProxy : $authSuccess;
+		} elseif ($bypassLogin){
+			$id = $this->config['localLoginUserId'];
+			$result = $this->getUserById($id);
+			$authSuccess = true;
 		} else {
 			// Has oAuth Token!
 			switch ($oAuthType) {
@@ -3794,7 +3818,7 @@ class Organizr
 			}
 			if ($userExists) {
 				//does org password need to be updated
-				if (!$passwordMatches) {
+				if (!$passwordMatches && $password) {
 					$this->updateUserPassword($password, $result['id']);
 					$this->setLoggerChannel('Authentication', $username);
 					$this->logger->info('User Password updated from backend');
@@ -4409,7 +4433,8 @@ class Organizr
 				'agent' => isset($_SERVER ['HTTP_USER_AGENT']) ? $_SERVER ['HTTP_USER_AGENT'] : null,
 				'oAuthLogin' => isset($_COOKIE['oAuth']),
 				'local' => $this->isLocal(),
-				'ip' => $this->userIP()
+				'ip' => $this->userIP(),
+				'bypass' => $this->config['bypassLoginForLocal'] && $this->config['localLoginUserId'] && $this->isLocal() == true
 			],
 			'login' => [
 				'rememberMe' => $this->config['rememberMe'],
@@ -4611,14 +4636,28 @@ class Organizr
 						$class .= ' faded';
 					}
 					break;
-				case 'homepageOrderembynowplaying':
-				case 'homepageOrderembyrecent':
-					$class = 'bg-emby';
-					$image = 'plugins/images/tabs/emby.png';
-					if (!$this->config['homepageEmbyEnabled']) {
-						$class .= ' faded';
-					}
-					break;
+			case 'homepageOrderembynowplaying':
+			case 'homepageOrderembyrecent':
+				$class = 'bg-emby';
+				$image = 'plugins/images/tabs/emby.png';
+				if (!$this->config['homepageEmbyEnabled']) {
+					$class .= ' faded';
+				}
+				break;
+			case 'homepageOrderEmbyLiveTVTracker':
+				$class = 'bg-emby';
+				$image = 'plugins/images/homepage/embyLiveTVTracker.png';
+				if (!$this->config['homepageEmbyLiveTVTrackerEnabled']) {
+					$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';
@@ -4711,6 +4750,13 @@ class Organizr
 						$class .= ' faded';
 					}
 					break;
+				case 'homepageOrderPromPage':
+					$class = 'bg-info';
+					$image = 'plugins/images/tabs/prompage.png';
+					if (!$this->config['homepagePromPageEnabled']) {
+						$class .= ' faded';
+					}
+					break;
 				case 'homepageOrderWeatherAndAir':
 					$class = 'bg-success';
 					$image = 'plugins/images/tabs/wind.png';
@@ -4800,6 +4846,11 @@ class Organizr
 		$this->groupOptions = $this->groupSelect();
 	}
 
+	public function setUserOptionsVariable()
+	{
+		$this->userOptions = $this->userSelect();
+	}
+
 	public function getSettingsHomepageItem($item)
 	{
 		$items = $this->getSettingsHomepage();
@@ -5137,7 +5188,7 @@ class Organizr
 		];
 		return $this->processQueries($response);
 	}
-
+	
 	public function getNextCategoryId()
 	{
 		$response = [
@@ -7281,7 +7332,7 @@ class Organizr
 		return $this->processQueries($response);
 	}
 
-	public function youtubeSearch($query)
+public function youtubeSearch($query)
 	{
 		if (!$query) {
 			$this->setAPIResponse('error', 'No query supplied', 422);
@@ -7297,7 +7348,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);

+ 62 - 1
api/config/default.php

@@ -377,6 +377,58 @@ return [
 	'homepageOrderAdguard' => '42',
 	'homepageOrderProwlarr' => '43',
 	'homepageOrderUptimeKuma' => '44',
+  'homepageOrderPromPage' => '45',
+	'homepageOrderEmbyLiveTVTracker' => '46',
+	'homepageOrderUserWatchStats' => '47',
+	'homepageOrderJellyStat' => '48',
+	'homepageEmbyLiveTVTrackerEnabled' => false,
+	'homepageEmbyLiveTVTrackerAuth' => '1',
+	'homepageEmbyLiveTVTrackerRefresh' => '5',
+	'homepageEmbyLiveTVTrackerDaysShown' => '7',
+	'homepageEmbyLiveTVTrackerCompactView' => false,
+	'homepageEmbyLiveTVTrackerShowDuration' => true,
+	'homepageEmbyLiveTVTrackerShowSeriesInfo' => true,
+	'homepageEmbyLiveTVTrackerShowUserInfo' => false,
+	'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,
@@ -707,7 +759,16 @@ return [
 	'homepageUptimeKumaHeaderToggle' => true,
 	'homepageUptimeKumaCompact' => true,
 	'homepageUptimeKumaShowLatency' => true,
+	'homepagePromPageEnabled' => false,
+	'promPageURL' => '',
+	'homepagePromPageRefresh' => '60000',
+	'homepagePromPageHeader' => 'Status Page',
+	'homepagePromPageHeaderToggle' => true,
+	'homepagePromPageCompact' => true,
+	'homepagePromPageShowUptime' => true,
 	'checkForUpdate' => true,
 	'socksDebug' => false,
-	'maxSocksDebugSize' => 100
+	'maxSocksDebugSize' => 100,
+	'bypassLoginForLocal' => false,
+	'localLoginUserId' => "1"
 ];

+ 4 - 0
api/functions.php

@@ -10,6 +10,10 @@ foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'functions' . DIRECTORY_SEPARATOR
 foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'homepage' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
 	require_once $filename;
 }
+// Include EmbyLiveTVTracker trait before class loading
+if (file_exists(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'embyLiveTVTracker' . DIRECTORY_SEPARATOR . 'api.php')) {
+	require_once __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'embyLiveTVTracker' . DIRECTORY_SEPARATOR . 'api.php';
+}
 foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
 	require_once $filename;
 }

+ 8 - 0
api/functions/option-functions.php

@@ -23,6 +23,14 @@ trait OptionsFunction
 			'value' => $this->config[$name] ?? ''
 		];
 		switch ($type) {
+			case 'orguser':
+				$this->setUserOptionsVariable();
+				$settingMerge = [
+					'type' => 'select',
+					'label' => 'Organizr User',
+					'options' => $this->userOptions
+				];
+				break;
 			case 'enable':
 				$settingMerge = [
 					'type' => 'switch',

+ 13 - 0
api/functions/organizr-functions.php

@@ -638,6 +638,19 @@ trait OrganizrFunctions
 		return $select;
 	}
 
+	public function userSelect()
+	{
+		$users = $this->getAllUsers();
+		$select = [];
+		foreach ($users as $key => $value) {
+			$select[] = array(
+				'name' => $value['username'],
+				'value' => $value['id']
+			);
+		}
+		return $select;
+	}
+
 	public function showLogin()
 	{
 		if ($this->config['hideRegistration'] == false) {

+ 801 - 0
api/homepage/embyLiveTVTracker.php

@@ -0,0 +1,801 @@
+<?php
+
+trait EmbyLiveTVTrackerHomepageItem
+{
+    public function embyLiveTVTrackerSettingsArray($infoOnly = false)
+    {
+        $homepageInformation = [
+            'name' => 'EmbyLiveTVTracker',
+            'enabled' => strpos('personal', $this->config['license']) !== false,
+            'image' => 'plugins/images/homepage/embyLiveTVTracker.png',
+            'category' => 'Media Server',
+            'settingsArray' => __FUNCTION__
+        ];
+        if ($infoOnly) {
+            return $homepageInformation;
+        }
+        $homepageSettings = [
+            'debug' => true,
+            'settings' => [
+                'Enable' => [
+                    $this->settingsOption('enable', 'homepageEmbyLiveTVTrackerEnabled'),
+                    $this->settingsOption('auth', 'homepageEmbyLiveTVTrackerAuth'),
+                ],
+                'Connection' => [
+                    $this->settingsOption('url', 'embyURL'),
+                    $this->settingsOption('token', 'embyToken'),
+                    $this->settingsOption('disable-cert-check', 'embyDisableCertCheck'),
+                    $this->settingsOption('use-custom-certificate', 'embyUseCustomCertificate'),
+                ],
+                'Display Options' => [
+                    $this->settingsOption('number', 'homepageEmbyLiveTVTrackerRefresh', ['label' => 'Auto-refresh Interval (minutes)', 'min' => 1, 'max' => 60]),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerCompactView', ['label' => 'Use Compact View']),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerShowDuration', ['label' => 'Show Recording Duration']),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerShowSeriesInfo', ['label' => 'Show Series Information']),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerShowUserInfo', ['label' => 'Show User Information']),
+                    $this->settingsOption('number', 'homepageEmbyLiveTVTrackerMaxItems', ['label' => 'Maximum Scheduled Items', 'min' => 5, 'max' => 50]),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerShowCompleted', ['label' => 'Show Completed Recordings']),
+                    $this->settingsOption('number', 'homepageEmbyLiveTVTrackerDaysShown', ['label' => 'Days of Completed Recordings', 'min' => 1, 'max' => 30]),
+                    $this->settingsOption('number', 'homepageEmbyLiveTVTrackerMaxCompletedItems', ['label' => 'Maximum Completed Items', 'min' => 5, 'max' => 50]),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerDebug', ['label' => 'Enable Debug Logging']),
+                ],
+                'Test Connection' => [
+                    $this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
+                    $this->settingsOption('test', 'embyLiveTVTracker'),
+                ]
+            ]
+        ];
+        return array_merge($homepageInformation, $homepageSettings);
+    }
+
+    public function testConnectionEmbyLiveTVTracker()
+    {
+        if (!$this->homepageItemPermissions($this->embyLiveTVTrackerHomepagePermissions('test'), true)) {
+            return false;
+        }
+        $url = $this->qualifyURL($this->config['embyURL']);
+        $url = $url . "/emby/System/Info?api_key=" . $this->config['embyToken'];
+        $options = $this->requestOptions($url, null, $this->config['embyDisableCertCheck'], $this->config['embyUseCustomCertificate']);
+        try {
+            $response = Requests::get($url, [], $options);
+            if ($response->success) {
+                $info = json_decode($response->body, true);
+                if (isset($info['ServerName'])) {
+                    // Test LiveTV functionality
+                    $liveTvUrl = $this->qualifyURL($this->config['embyURL']) . '/emby/LiveTv/Info?api_key=' . $this->config['embyToken'];
+                    try {
+                        $liveTvResponse = Requests::get($liveTvUrl, [], $options);
+                        $liveTvInfo = json_decode($liveTvResponse->body, true);
+                        $hasLiveTV = isset($liveTvInfo['Services']) && count($liveTvInfo['Services']) > 0;
+                        $message = 'Successfully connected to ' . $info['ServerName'];
+                        if ($hasLiveTV) {
+                            $message .= ' with LiveTV support enabled';
+                        } else {
+                            $message .= ' (Warning: LiveTV may not be configured)';
+                        }
+                        $this->setAPIResponse('success', $message, 200);
+                    } catch (Exception $e) {
+                        $this->setAPIResponse('success', 'Connected to ' . $info['ServerName'] . ' but LiveTV status unknown', 200);
+                    }
+                } else {
+                    $this->setAPIResponse('error', 'Invalid response from Emby server', 500);
+                }
+                return true;
+            } else {
+                $this->setAPIResponse('error', 'Emby Connection Error', 500);
+                return false;
+            }
+        } catch (Requests_Exception $e) {
+            $this->setResponse(500, $e->getMessage());
+            return false;
+        }
+    }
+
+    public function embyLiveTVTrackerHomepagePermissions($key = null)
+    {
+        $permissions = [
+            'test' => [
+                'enabled' => [
+                    'homepageEmbyLiveTVTrackerEnabled',
+                ],
+                'auth' => [
+                    'homepageEmbyLiveTVTrackerAuth',
+                ],
+                'not_empty' => [
+                    'embyURL',
+                    'embyToken'
+                ]
+            ],
+            'main' => [
+                'enabled' => [
+                    'homepageEmbyLiveTVTrackerEnabled'
+                ],
+                'auth' => [
+                    'homepageEmbyLiveTVTrackerAuth'
+                ],
+                'not_empty' => [
+                    'embyURL',
+                    'embyToken'
+                ]
+            ]
+        ];
+        return $this->homepageCheckKeyPermissions($key, $permissions);
+    }
+
+    public function homepageOrderEmbyLiveTVTracker()
+    {
+        if ($this->homepageItemPermissions($this->embyLiveTVTrackerHomepagePermissions('main'))) {
+            $refreshInterval = ($this->config['homepageEmbyLiveTVTrackerRefresh'] ?? 5) * 60000; // Convert minutes to milliseconds
+            $compactView = ($this->config['homepageEmbyLiveTVTrackerCompactView'] ?? false) ? 'true' : 'false';
+            $showDuration = ($this->config['homepageEmbyLiveTVTrackerShowDuration'] ?? true) ? 'true' : 'false';
+            $showSeriesInfo = ($this->config['homepageEmbyLiveTVTrackerShowSeriesInfo'] ?? true) ? 'true' : 'false';
+            $showUserInfo = ($this->config['homepageEmbyLiveTVTrackerShowUserInfo'] ?? false) ? 'true' : 'false';
+            $maxItems = $this->config['homepageEmbyLiveTVTrackerMaxItems'] ?? 10;
+            $showCompleted = ($this->config['homepageEmbyLiveTVTrackerShowCompleted'] ?? true) ? 'true' : 'false';
+            $daysShown = $this->config['homepageEmbyLiveTVTrackerDaysShown'] ?? 7;
+            $maxCompletedItems = $this->config['homepageEmbyLiveTVTrackerMaxCompletedItems'] ?? 5;
+
+            $panelClass = ($compactView === 'true') ? 'panel-compact' : '';
+            $statsClass = ($compactView === 'true') ? 'col-sm-6' : 'col-sm-3';
+
+            return '
+            <div id="' . __FUNCTION__ . '">
+                <div class="white-box ' . $panelClass . '">
+                    <div class="white-box-header">
+                        <i class="fa fa-tv"></i> Emby LiveTV Tracker
+                        <span class="pull-right">
+                            <small id="embylivetv-last-update" class="text-muted"></small>
+                            <button class="btn btn-xs btn-primary" onclick="refreshEmbyLiveTVData()" title="Refresh Data">
+                                <i class="fa fa-refresh" id="embylivetv-refresh-icon"></i>
+                            </button>
+                        </span>
+                    </div>
+                    <div class="white-box-content">
+                        <div class="row" id="embylivetv-stats">
+                            <div class="' . $statsClass . '">
+                                <div class="text-center">
+                                    <h3 id="embylivetv-active-timers" class="text-success">-</h3>
+                                    <small>Active Timers</small>
+                                </div>
+                            </div>
+                            <div class="' . $statsClass . '">
+                                <div class="text-center">
+                                    <h3 id="embylivetv-series-timers" class="text-info">-</h3>
+                                    <small>Series Timers</small>
+                                </div>
+                            </div>
+                            ' . (($compactView === 'false') ? '
+                            <div class="' . $statsClass . '">
+                                <div class="text-center">
+                                    <h3 id="embylivetv-today-recordings" class="text-warning">-</h3>
+                                    <small>Today\'s Recordings</small>
+                                </div>
+                            </div>
+                            <div class="' . $statsClass . '">
+                                <div class="text-center">
+                                    <h3 id="embylivetv-total-recordings" class="text-primary">-</h3>
+                                    <small>Total Recordings</small>
+                                </div>
+                            </div>
+                            ' : '') . '
+                        </div>
+
+                        <!-- Scheduled Recordings Table -->
+                        <div class="row" style="margin-top: 20px;">
+                            <div class="col-lg-12">
+                                <h4>
+                                    Scheduled Recordings
+                                    <small class="text-muted">Upcoming and active timers</small>
+                                </h4>
+                                <div class="table-responsive">
+                                    <table class="table table-hover table-striped table-condensed">
+                                        <thead>
+                                            <tr>
+                                                <th width="120">Date</th>
+                                                <th>Series</th>
+                                                ' . (($showSeriesInfo === 'true') ? '<th>Episode Title</th>' : '') . '
+                                                <th>Channel</th>
+                                                ' . (($showUserInfo === 'true') ? '<th>User</th>' : '') . '
+                                                ' . (($showDuration === 'true') ? '<th width="80">Duration</th>' : '') . '
+                                                <th width="70">Status</th>
+                                            </tr>
+                                        </thead>
+                                        <tbody id="embylivetv-scheduled-table">
+                                            <tr>
+                                                <td colspan="' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '" class="text-center">
+                                                    <i class="fa fa-spinner fa-spin"></i> Loading...
+                                                </td>
+                                            </tr>
+                                        </tbody>
+                                    </table>
+                                </div>
+                            </div>
+                        </div>
+                        
+                        <!-- Completed Recordings Table (only if enabled) -->
+                        ' . (($showCompleted === 'true') ? '
+                        <div class="row" style="margin-top: 20px;">
+                            <div class="col-lg-12">
+                                <h4>
+                                    Completed Recordings
+                                    <small class="text-muted">Recent recordings</small>
+                                </h4>
+                                <div class="table-responsive">
+                                    <table class="table table-hover table-striped table-condensed">
+                                        <thead>
+                                            <tr>
+                                                <th width="120">Date</th>
+                                                <th>Series</th>
+                                                ' . (($showSeriesInfo === 'true') ? '<th>Series</th>' : '') . '
+                                                ' . (($showDuration === 'true') ? '<th width="80">Duration</th>' : '') . '
+                                                <th width="70">Status</th>
+                                            </tr>
+                                        </thead>
+                                        <tbody id="embylivetv-completed-table">
+                                            <tr>
+                                                <td colspan="' . (3 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '" class="text-center">
+                                                    <i class="fa fa-spinner fa-spin"></i> Loading...
+                                                </td>
+                                            </tr>
+                                        </tbody>
+                                    </table>
+                                </div>
+                                </div>
+                        </div>
+                        ' : '') . '
+                    </div>
+                </div>
+            </div>
+
+            <style>
+            .panel-compact .white-box-content { padding: 10px; }
+            .panel-compact h3 { margin: 5px 0; font-size: 1.8em; }
+            .panel-compact small { font-size: 0.85em; }
+            #' . __FUNCTION__ . ' .table-condensed td { padding: 4px 8px; font-size: 0.9em; }
+            #' . __FUNCTION__ . ' .status-success { color: #5cb85c; }
+            #' . __FUNCTION__ . ' .status-recording { color: #d9534f; }
+            #' . __FUNCTION__ . ' .status-scheduled { color: #f0ad4e; }
+            </style>
+
+            <script>
+            var embyLiveTVRefreshTimer;
+            var embyLiveTVLastRefresh = 0;
+
+            function refreshEmbyLiveTVData() {
+                var refreshIcon = $("#embylivetv-refresh-icon");
+                refreshIcon.addClass("fa-spin");
+
+                // Show loading state
+                $("#embylivetv-stats h3").text("-");
+                $("#embylivetv-scheduled-table").html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center\"><i class=\"fa fa-spinner fa-spin\"></i> Loading...</td></tr>");
+                ' . (($showCompleted === 'true') ? '$("#embylivetv-completed-table").html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center\"><i class=\"fa fa-spinner fa-spin\"></i> Loading...</td></tr>");' : '') . ';
+
+                // Load stats and activity
+                homepageEmbyLiveTVTrackerStats()
+                .always(function() {
+                    refreshIcon.removeClass("fa-spin");
+                    embyLiveTVLastRefresh = Date.now();
+                    updateEmbyLiveTVLastRefreshTime();
+                });
+            }
+
+            function updateEmbyLiveTVLastRefreshTime() {
+                if (embyLiveTVLastRefresh > 0) {
+                    var ago = Math.floor((Date.now() - embyLiveTVLastRefresh) / 1000);
+                    var timeText = ago < 60 ? ago + "s ago" : Math.floor(ago / 60) + "m ago";
+                    $("#embylivetv-last-update").text("Updated " + timeText);
+                }
+            }
+
+            function homepageEmbyLiveTVTrackerStats() {
+                return organizrAPI2("GET", "api/v2/homepage/embyLiveTVTracker/stats")
+                .done(function(data) {
+                    console.log("Stats response received:", data);
+                    if (data && data.response && data.response.result === "success" && data.response.data) {
+                        console.log("Stats data is valid, loading activity...");
+                        $("#embylivetv-active-timers").text(data.response.data.activeTimers || "0");
+                        $("#embylivetv-series-timers").text(data.response.data.seriesTimers || "0");
+                        ' . (($compactView === 'false') ? '
+                        $("#embylivetv-today-recordings").text(data.response.data.todaysRecordings || "0");
+                        $("#embylivetv-total-recordings").text(data.response.data.totalRecordings || "0");
+                        ' : '') . '
+                        
+                        // Load activity
+                        homepageEmbyLiveTVTrackerActivity();
+                    } else {
+                        console.error("Stats response structure issue:", {
+                            hasData: !!data,
+                            hasResponse: !!(data && data.response),
+                            result: data && data.response && data.response.result,
+                            hasResponseData: !!(data && data.response && data.response.data)
+                        });
+                        console.error("Failed to load Emby LiveTV stats:", data.response ? data.response.message : "Unknown error");
+                        $("#embylivetv-stats h3").text("?").attr("title", "Error loading data");
+                    }
+                })
+                .fail(function(xhr, status, error) {
+                    console.error("Error loading Emby LiveTV stats:", error);
+                    $("#embylivetv-stats h3").text("!").attr("title", "Connection failed");
+                });
+            }
+
+            function homepageEmbyLiveTVTrackerActivity() {
+                console.log("Activity function called - making API request...");
+                return organizrAPI2("GET", "api/v2/homepage/embyLiveTVTracker/activity?days=' . ($daysShown ?: 7) . '\u0026limit=' . ($maxItems ?: 10) . '")
+                .done(function(data) {
+                    console.log("Activity response received:", data);
+                    console.log("Response structure check:", {
+                        hasData: !!data,
+                        hasResponse: !!(data && data.response),
+                        result: data && data.response && data.response.result,
+                        hasResponseData: !!(data && data.response && data.response.data),
+                        hasActivities: !!(data && data.response && data.response.data && data.response.data.activities),
+                        activitiesLength: data && data.response && data.response.data && data.response.data.activities ? data.response.data.activities.length : 0
+                    });
+                    if (data && data.response && data.response.result === "success" && data.response.data) {
+                        var scheduledRecordings = data.response.data.scheduledRecordings || [];
+                        var completedRecordings = data.response.data.completedRecordings || [];
+                        
+                        console.log("Scheduled recordings:", scheduledRecordings.length);
+                        console.log("Completed recordings:", completedRecordings.length);
+                        
+                        // Apply limits to each category separately to ensure we show both types
+                        var maxScheduled = Math.floor(' . $maxItems . ' * 0.7); // 70% for scheduled
+                        var maxCompleted = ' . $maxItems . ' - maxScheduled; // 30% for completed
+                        
+                        // If we have fewer scheduled than the 70% allocation, give more space to completed
+                        if (scheduledRecordings.length < maxScheduled) {
+                            maxCompleted = Math.min(completedRecordings.length, ' . $maxItems . ' - scheduledRecordings.length);
+                        }
+                        // If we have fewer completed than the 30% allocation, give more space to scheduled
+                        if (completedRecordings.length < maxCompleted) {
+                            maxScheduled = Math.min(scheduledRecordings.length, ' . $maxItems . ' - completedRecordings.length);
+                        }
+                        
+                        var scheduledActivities = scheduledRecordings.slice(0, maxScheduled);
+                        var completedActivities = completedRecordings.slice(0, maxCompleted);
+                        
+                        console.log("Split - Scheduled activities:", scheduledActivities.length);
+                        console.log("Split - Completed activities:", completedActivities.length);
+
+                        // Helper function to format scheduled activity rows
+                        function formatScheduledRow(activity) {
+                            var date = new Date(activity.date);
+                            var formattedDate = date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute:"2-digit"});
+                            var status = activity.status || "Scheduled";
+                            var statusClass = status.toLowerCase() === "completed" ? "status-success" :
+                                            status.toLowerCase() === "recording" ? "status-recording" : "status-scheduled";
+
+                            return "<tr>" +
+                                "<td><small>" + formattedDate + "</small></td>" +
+                                "<td>" + (activity.seriesName || activity.name || "-") + "</td>" +
+                                ' . (($showSeriesInfo === 'true') ? '"<td><small>" + (activity.episodeTitle || activity.name || "-") + "</small></td>" +' : '') . '
+                                "<td><small>" + (activity.channelName || "-") + "</small></td>" +
+                                ' . (($showUserInfo === 'true') ? '"<td><small>" + (activity.userName || "-") + "</small></td>" +' : '') . '
+                                ' . (($showDuration === 'true') ? '"<td><small>" + (activity.duration || "-") + "</small></td>" +' : '') . '
+                                "<td><span class=\"" + statusClass + "\"><i class=\"fa fa-circle\"></i></span></td>" +
+                                "</tr>";
+                        }
+                        
+                        // Helper function to format completed activity rows (no channel or user columns)
+                        function formatCompletedRow(activity) {
+                            var date = new Date(activity.date);
+                            var formattedDate = date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute:"2-digit"});
+                            var status = activity.status || "Completed";
+                            var statusClass = "status-success";
+
+                            return "<tr>" +
+                                "<td><small>" + formattedDate + "</small></td>" +
+                                "<td>" + (activity.seriesName || activity.name || "-") + "</td>" +
+                                ' . (($showSeriesInfo === 'true') ? '"<td><small>" + (activity.seriesName || "-") + "</small></td>" +' : '') . '
+                                ' . (($showDuration === 'true') ? '"<td><small>" + (activity.duration || "-") + "</small></td>" +' : '') . '
+                                "<td><span class=\"" + statusClass + "\"><i class=\"fa fa-circle\"></i></span></td>" +
+                                "</tr>";
+                        }
+                        
+                        // Populate scheduled recordings table
+                        var scheduledTable = $("#embylivetv-scheduled-table");
+                        if (scheduledActivities.length === 0) {
+                            scheduledTable.html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0)) . '\" class=\"text-center text-muted\">No scheduled recordings</td></tr>");
+                        } else {
+                            var scheduledRows = scheduledActivities.map(formatScheduledRow).join("");
+                            scheduledTable.html(scheduledRows);
+                        }
+                        
+                        // Populate completed recordings table (only if enabled and table exists)
+                        ' . (($showCompleted === 'true') ? '
+                        var completedTable = $("#embylivetv-completed-table");
+                        if (completedActivities.length === 0) {
+                            completedTable.html("<tr><td colspan=\"' . (3 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center text-muted\">No completed recordings</td></tr>");
+                        } else {
+                            var completedRows = completedActivities.map(formatCompletedRow).join("");
+                            completedTable.html(completedRows);
+                        }
+                        ' : '') . '
+                    } else {
+                        $("#embylivetv-scheduled-table").html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0)) . '\" class=\"text-center text-muted\">No activity data available</td></tr>");
+                        ' . (($showCompleted === 'true') ? '$("#embylivetv-completed-table").html("<tr><td colspan=\"' . (3 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center text-muted\">No activity data available</td></tr>");' : '') . '
+                    }
+                })
+                .fail(function(xhr, status, error) {
+                    console.error("Error loading Emby LiveTV activity:", error);
+                    $("#embylivetv-scheduled-table").html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center text-danger\">Error loading activity data</td></tr>");
+                    ' . (($showCompleted === 'true') ? '$("#embylivetv-completed-table").html("<tr><td colspan=\"' . (3 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center text-danger\">Error loading activity data</td></tr>");' : '') . '
+                });
+            }
+
+            // Auto-refresh setup
+            var refreshInterval = ' . $refreshInterval . ';
+            if (refreshInterval > 0) {
+                embyLiveTVRefreshTimer = setInterval(function() {
+                    refreshEmbyLiveTVData();
+                }, refreshInterval);
+            }
+
+            // Update time display every 30 seconds
+            setInterval(updateEmbyLiveTVLastRefreshTime, 30000);
+
+            // Initial load
+            $(document).ready(function() {
+                refreshEmbyLiveTVData();
+            });
+
+            // Cleanup timer when page unloads
+            $(window).on("beforeunload", function() {
+                if (embyLiveTVRefreshTimer) {
+                    clearInterval(embyLiveTVRefreshTimer);
+                }
+            });
+            </script>
+            ';
+        }
+    }
+
+    public function getHomepageEmbyLiveTVStats()
+    {
+        if (!$this->homepageItemPermissions($this->embyLiveTVTrackerHomepagePermissions('main'), true)) {
+            return false;
+        }
+        
+        if (!$this->config['embyURL'] || !$this->config['embyToken']) {
+            $this->setAPIResponse('error', 'Emby URL or Token not configured', 500);
+            return false;
+        }
+        
+        try {
+            $options = $this->requestOptions($this->config['embyURL'], null, $this->config['embyDisableCertCheck'], $this->config['embyUseCustomCertificate']);
+            $baseUrl = $this->qualifyURL($this->config['embyURL']);
+            
+            $stats = [
+                'activeTimers' => 0,
+                'seriesTimers' => 0,
+                'todaysRecordings' => 0,
+                'totalRecordings' => 0,
+                'recentRecordings' => []
+            ];
+            
+            // Get active timers
+            $timersUrl = $baseUrl . '/emby/LiveTv/Timers?api_key=' . $this->config['embyToken'];
+            try {
+                $timersResponse = Requests::get($timersUrl, [], $options);
+                if ($timersResponse->success) {
+                    $timers = json_decode($timersResponse->body, true);
+                    $stats['activeTimers'] = count($timers['Items'] ?? []);
+                }
+            } catch (Exception $e) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get timers: ' . $e->getMessage());
+            }
+            
+            // Get series timers
+            $seriesTimersUrl = $baseUrl . '/emby/LiveTv/SeriesTimers?api_key=' . $this->config['embyToken'];
+            try {
+                $seriesTimersResponse = Requests::get($seriesTimersUrl, [], $options);
+                if ($seriesTimersResponse->success) {
+                    $seriesTimers = json_decode($seriesTimersResponse->body, true);
+                    $stats['seriesTimers'] = count($seriesTimers['Items'] ?? []);
+                }
+            } catch (Exception $e) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get series timers: ' . $e->getMessage());
+            }
+            
+            // Get recordings from the last 90 days
+            $recordingsUrl = $baseUrl . '/emby/LiveTv/Recordings?api_key=' . $this->config['embyToken'] . '&StartIndex=0&Limit=50&Fields=Overview,DateCreated&SortBy=DateCreated&SortOrder=Descending';
+            try {
+                $recordingsResponse = Requests::get($recordingsUrl, [], $options);
+                if ($recordingsResponse->success) {
+                    $recordings = json_decode($recordingsResponse->body, true);
+                    $allRecordings = $recordings['Items'] ?? [];
+                    
+                    // Count today's recordings
+                    $today = date('Y-m-d');
+                    $todaysCount = 0;
+                    $recentRecordings = [];
+                    
+                    foreach ($allRecordings as $recording) {
+                        if (isset($recording['DateCreated'])) {
+                            $recordDate = date('Y-m-d', strtotime($recording['DateCreated']));
+                            if ($recordDate === $today) {
+                                $todaysCount++;
+                            }
+                            
+                            // Add to recent recordings list
+                            $recentRecordings[] = [
+                                'date' => $recordDate,
+                                'program' => $recording['Name'] ?? 'Unknown',
+                                'series' => $recording['SeriesName'] ?? '',
+                                'channel' => $recording['ChannelName'] ?? 'Unknown Channel',
+                                'status' => 'Completed'
+                            ];
+                        }
+                    }
+                    
+                    $stats['todaysRecordings'] = $todaysCount;
+                    $stats['totalRecordings'] = $recordings['TotalRecordCount'] ?? count($allRecordings);
+                    $stats['recentRecordings'] = array_slice($recentRecordings, 0, 10); // Limit to 10 recent recordings
+                }
+            } catch (Exception $e) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get recordings: ' . $e->getMessage());
+            }
+            
+            $this->setAPIResponse('success', 'LiveTV stats retrieved successfully', 200, $stats);
+            return true;
+            
+        } catch (Exception $e) {
+            $this->setAPIResponse('error', 'Failed to retrieve LiveTV stats: ' . $e->getMessage(), 500);
+            return false;
+        }
+    }
+
+    public function getHomepageEmbyLiveTVActivity()
+    {
+        $debugEnabled = $this->config['homepageEmbyLiveTVTrackerDebug'] ?? false;
+        
+        if (!$this->homepageItemPermissions($this->embyLiveTVTrackerHomepagePermissions('main'), true)) {
+            if ($debugEnabled) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Permission denied for user');
+            }
+            $this->setAPIResponse('error', 'Permission denied', 403);
+            return false;
+        }
+        
+        if (!$this->config['embyURL'] || !$this->config['embyToken']) {
+            $this->setAPIResponse('error', 'Emby URL or Token not configured', 500);
+            return false;
+        }
+        
+        try {
+            if ($debugEnabled) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->info('Activity method called - starting execution');
+            }
+            $options = $this->requestOptions($this->config['embyURL'], null, $this->config['embyDisableCertCheck'], $this->config['embyUseCustomCertificate']);
+            $baseUrl = $this->qualifyURL($this->config['embyURL']);
+            if ($debugEnabled) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->info('Base URL configured: ' . $baseUrl);
+            }
+            
+            $scheduledRecordings = [];
+            $completedRecordings = [];
+            $maxItems = intval($this->config['homepageEmbyLiveTVTrackerMaxItems'] ?? 10);
+            $showCompleted = $this->config['homepageEmbyLiveTVTrackerShowCompleted'] ?? true;
+            $maxCompletedItems = intval($this->config['homepageEmbyLiveTVTrackerMaxCompletedItems'] ?? 5);
+            
+            // Get user info if user info is enabled
+            $userMap = [];
+            $showUserInfo = $this->config['homepageEmbyLiveTVTrackerShowUserInfo'] ?? false;
+            if ($showUserInfo) {
+                $usersUrl = $baseUrl . '/emby/Users?api_key=' . $this->config['embyToken'];
+                try {
+                    $usersResponse = Requests::get($usersUrl, [], $options);
+                    if ($usersResponse->success) {
+                        $users = json_decode($usersResponse->body, true);
+                        foreach ($users as $user) {
+                            $userMap[$user['Id']] = $user['Name'];
+                        }
+                        if ($debugEnabled) {
+                            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Retrieved ' . count($userMap) . ' users for mapping');
+                        }
+                    }
+                } catch (Exception $e) {
+                    $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get users: ' . $e->getMessage());
+                }
+            }
+            
+            // Get scheduled recordings (active timers)
+            $timersUrl = $baseUrl . '/emby/LiveTv/Timers?api_key=' . $this->config['embyToken'] . '&Fields=ChannelName,ChannelId,SeriesName,ProgramInfo,StartDate,EndDate,UserId';
+            if ($debugEnabled) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->info('Fetching timers from URL: ' . $timersUrl);
+            }
+            try {
+                $timersResponse = Requests::get($timersUrl, [], $options);
+                if ($debugEnabled) {
+                    $this->setLoggerChannel('EmbyLiveTVTracker')->info('Timers API response status: ' . ($timersResponse->success ? 'success' : 'failed'));
+                    $this->setLoggerChannel('EmbyLiveTVTracker')->info('Timers API response code: ' . $timersResponse->status_code);
+                }
+                if ($timersResponse->success) {
+                    $timers = json_decode($timersResponse->body, true);
+                    $allTimers = $timers['Items'] ?? [];
+                    if ($debugEnabled) {
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Retrieved ' . count($allTimers) . ' timers from Emby API');
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Timers API raw response length: ' . strlen($timersResponse->body));
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('First timer sample: ' . json_encode(array_slice($allTimers, 0, 1)));
+                    }
+                    
+                    // Sort timers by start date
+                    usort($allTimers, function($a, $b) {
+                        $aDate = $a['StartDate'] ?? '';
+                        $bDate = $b['StartDate'] ?? '';
+                        return strcmp($aDate, $bDate);
+                    });
+
+                    $timersToProcess = array_slice($allTimers, 0, intval($maxItems));
+                    if ($debugEnabled) {
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Processing ' . count($timersToProcess) . ' timers (maxItems: ' . $maxItems . ')');
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('All timers count before slice: ' . count($allTimers));
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('MaxItems value: ' . $maxItems . ' (type: ' . gettype($maxItems) . ')');
+                    }
+                    
+                    foreach ($timersToProcess as $index => $timer) {
+                        if ($debugEnabled) {
+                            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Processing timer ' . ($index + 1) . ': ' . json_encode([
+                                'Name' => $timer['Name'] ?? 'no name',
+                                'StartDate' => $timer['StartDate'] ?? 'no start date',
+                                'EndDate' => $timer['EndDate'] ?? 'no end date',
+                                'ChannelName' => $timer['ChannelName'] ?? 'no channel name',
+                                'Status' => $timer['Status'] ?? 'no status',
+                                'UserId' => $timer['UserId'] ?? 'no user'
+                            ]));
+                        }
+                        
+                        // Calculate duration
+                        $duration = '-';
+                        if (isset($timer['StartDate']) && isset($timer['EndDate'])) {
+                            $start = strtotime($timer['StartDate']);
+                            $end = strtotime($timer['EndDate']);
+                            if ($start && $end) {
+                                $minutes = round(($end - $start) / 60);
+                                $hours = floor($minutes / 60);
+                                $remainingMinutes = $minutes % 60;
+                                if ($hours > 0) {
+                                    $duration = sprintf('%dh %dm', $hours, $remainingMinutes);
+                                } else {
+                                    $duration = sprintf('%dm', $minutes);
+                                }
+                            }
+                        }
+                        
+                        // Get channel name - timers should have this information
+                        $channelName = $timer['ChannelName'] ?? null;
+                        if (empty($channelName) && !empty($timer['ChannelId'])) {
+                            $channelName = 'Channel ' . $timer['ChannelId'];
+                        } elseif (empty($channelName)) {
+                            $channelName = 'Unknown Channel';
+                        }
+                        
+                        // Get user name
+                        $userName = null;
+                        if ($showUserInfo && !empty($timer['UserId']) && isset($userMap[$timer['UserId']])) {
+                            $userName = $userMap[$timer['UserId']];
+                        }
+                        
+                        // Determine status based on timing
+                        $status = 'Scheduled';
+                        $startTime = strtotime($timer['StartDate'] ?? '');
+                        $endTime = strtotime($timer['EndDate'] ?? '');
+                        $now = time();
+                        
+                        if ($startTime && $endTime) {
+                            if ($now >= $startTime && $now <= $endTime) {
+                                $status = 'Recording';
+                            } elseif ($now > $endTime) {
+                                $status = 'Completed';
+                            }
+                        }
+                        
+                        // Get series name and episode title - try multiple approaches
+                        $seriesName = '';
+                        $episodeTitle = '';
+                        
+                        // Check for episode title first
+                        if (!empty($timer['ProgramInfo']['EpisodeTitle'])) {
+                            $episodeTitle = $timer['ProgramInfo']['EpisodeTitle'];
+                        }
+                        
+                        // Get series name
+                        if (!empty($timer['SeriesName'])) {
+                            $seriesName = $timer['SeriesName'];
+                        } elseif (!empty($timer['ProgramInfo']['SeriesName'])) {
+                            $seriesName = $timer['ProgramInfo']['SeriesName'];
+                        } elseif (!empty($timer['ProgramInfo']['Name'])) {
+                            $seriesName = $timer['ProgramInfo']['Name'];
+                        } elseif (!empty($timer['Name'])) {
+                            $seriesName = $timer['Name'];
+                        }
+                        
+                        // Use episode title if available, otherwise use program/series name
+                        $displayName = $episodeTitle ? $episodeTitle : ($timer['Name'] ?? ($timer['ProgramInfo']['Name'] ?? 'Unknown Program'));
+                        
+                        $activity = [
+                            'date' => $timer['StartDate'] ?? '',
+                            'name' => $displayName,
+                            'seriesName' => $seriesName,
+                            'episodeTitle' => $episodeTitle,
+                            'channelName' => $channelName,
+                            'userName' => $userName,
+                            'duration' => $duration,
+                            'status' => $status,
+                            'type' => 'timer'
+                        ];
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Created activity for timer ' . ($index + 1) . ': ' . json_encode($activity));
+                        
+                        // Debug timer date parsing
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Timer date debug - StartDate: "' . ($timer['StartDate'] ?? 'null') . '", parsed startTime: ' . ($startTime ? date('Y-m-d H:i:s', $startTime) : 'failed to parse') . ', EndDate: "' . ($timer['EndDate'] ?? 'null') . '", parsed endTime: ' . ($endTime ? date('Y-m-d H:i:s', $endTime) : 'failed to parse') . ', current time: ' . date('Y-m-d H:i:s', $now) . ', calculated status: ' . $status);
+                        
+                        $scheduledRecordings[] = $activity;
+                    }
+                }
+            } catch (Exception $e) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get timers for activity: ' . $e->getMessage());
+            }
+            
+            // Get completed recordings if enabled
+            if ($showCompleted) {
+                $recordingsUrl = $baseUrl . '/emby/LiveTv/Recordings?api_key=' . $this->config['embyToken'] . '&StartIndex=0&Limit=' . $maxCompletedItems . '&Fields=DateCreated,SeriesName,RunTimeTicks&SortBy=DateCreated&SortOrder=Descending';
+                $this->setLoggerChannel('EmbyLiveTVTracker')->info('Fetching completed recordings from URL: ' . $recordingsUrl);
+                try {
+                    $recordingsResponse = Requests::get($recordingsUrl, [], $options);
+                    if ($recordingsResponse->success) {
+                        $recordings = json_decode($recordingsResponse->body, true);
+                        $allRecordings = $recordings['Items'] ?? [];
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Retrieved ' . count($allRecordings) . ' completed recordings');
+                        
+                        foreach ($allRecordings as $recording) {
+                            if (isset($recording['DateCreated'])) {
+                                // Format duration
+                                $duration = '-';
+                                if (isset($recording['RunTimeTicks']) && $recording['RunTimeTicks'] > 0) {
+                                    $minutes = floor($recording['RunTimeTicks'] / 600000000);
+                                    $hours = floor($minutes / 60);
+                                    $remainingMinutes = $minutes % 60;
+                                    if ($hours > 0) {
+                                        $duration = sprintf('%dh %dm', $hours, $remainingMinutes);
+                                    } else {
+                                        $duration = sprintf('%dm', $minutes);
+                                    }
+                                }
+                                
+                                $completedRecordings[] = [
+                                    'date' => $recording['DateCreated'],
+                                    'name' => $recording['Name'] ?? 'Unknown Program',
+                                    'seriesName' => $recording['SeriesName'] ?? '',
+                                    'channelName' => 'Unknown Channel', // Completed recordings don't have reliable channel info
+                                    'userName' => null, // No user info available for completed recordings
+                                    'duration' => $duration,
+                                    'status' => 'Completed',
+                                    'type' => 'recording'
+                                ];
+                            }
+                        }
+                    }
+                } catch (Exception $e) {
+                    $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get completed recordings: ' . $e->getMessage());
+                }
+            }
+            
+            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Final scheduled recordings count: ' . count($scheduledRecordings));
+            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Final completed recordings count: ' . count($completedRecordings));
+            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Sample scheduled: ' . json_encode(array_slice($scheduledRecordings, 0, 1)));
+            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Sample completed: ' . json_encode(array_slice($completedRecordings, 0, 1)));
+            
+            $this->setAPIResponse('success', 'LiveTV activity retrieved successfully', 200, [
+                'scheduledRecordings' => $scheduledRecordings,
+                'completedRecordings' => $completedRecordings
+            ]);
+            return true;
+            
+        } catch (Exception $e) {
+            $this->setAPIResponse('error', 'Failed to retrieve LiveTV activity: ' . $e->getMessage(), 500);
+            return 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);
+    }
+}

+ 123 - 48
api/homepage/pihole.php

@@ -22,7 +22,7 @@ trait PiHoleHomepageItem
 					$this->settingsOption('auth', 'homepagePiholeAuth'),
 				],
 				'Connection' => [
-					$this->settingsOption('multiple-url', 'piholeURL', ['help' => 'Please make sure to use local IP address and port and to include \'/admin/\' at the end of the URL. You can add multiple Pi-holes by comma separating the URLs.', 'placeholder' => 'http(s)://hostname:port/admin/']),
+					$this->settingsOption('multiple-url', 'piholeURL', ['help' => 'Please make sure to use local IP address and port at the end of the URL. You can add multiple Pi-holes by comma separating the URLs.', 'placeholder' => 'http(s)://hostname:port/']),
 					$this->settingsOption('multiple-token', 'piholeToken'),
 				],
 				'Misc' => [
@@ -48,36 +48,10 @@ trait PiHoleHomepageItem
 		$errors = '';
 		$list = $this->csvHomepageUrlToken($this->config['piholeURL'], $this->config['piholeToken']);
 		foreach ($list as $key => $value) {
-			$url = $value['url'] . '/api.php?status';
-			if ($value['token'] !== '' && $value['token'] !== null) {
-				$url = $url . '&auth=' . $value['token'];
-			}
-			$ip = $this->qualifyURL($url, true)['host'];
-			try {
-				$response = Requests::get($url, [], []);
-				if ($response->success) {
-					$test = $this->testAndFormatString($response->body);
-					if (($test['type'] !== 'json')) {
-						$errors .= $ip . ': Response was not JSON';
-						$failed = true;
-					} else {
-						if (!isset($test['data']['status'])) {
-							$errors .= $ip . ': Missing API Token';
-							$failed = true;
-						}
-					}
-				}
-				if (!$response->success) {
-					$errors .= $ip . ': Unknown Failure';
-					$failed = true;
-				}
-			} catch (Requests_Exception $e) {
-				$failed = true;
-				$errors .= $ip . ': ' . $e->getMessage();
-				$this->setLoggerChannel('PiHole')->error($e);
-			};
+			$response = $this->getAuth($value['url'], $value['token']);
+			$errors = $response["errors"];
 		}
-		if ($failed) {
+		if ($errors != '') {
 			$this->setAPIResponse('error', $errors, 500);
 			return false;
 		} else {
@@ -120,6 +94,120 @@ trait PiHoleHomepageItem
 		}
 	}
 
+	public function getAuth($base_url, $token)
+	{
+		$sid = null;
+		if ($token === '' || $token === null) {
+			$errors .= $base_url . ': Missing API Token';
+			$this->setAPIResponse('error', $errors, 500);
+			return $errors;
+		}
+		try {
+			$sid = $this->doRequest($base_url, "createAuth", [], ['password' => $token]);
+			$this->cleanSessions($base_url, $sid);
+		} catch (Requests_Exception $e) {
+			$errors .= $ip . ': ' . $e->getMessage();
+			$this->setLoggerChannel('PiHole')->error($e);
+		};
+
+		return ["errors" => $errors, "sid" => $sid];
+	}
+
+	public function cleanSessions($base_url, $sid)
+	{
+		$sessions = $this->doRequest($base_url, "getAuths", ["sid" => $sid]);
+		foreach ($sessions as $session) {
+			//  Skip if not right user agent, skip if current session
+			if ($session['user_agent'] != 'Organizr' || $session['current_session'] == '1') {
+				continue;
+			}
+			$this->doRequest($base_url,"deleteAuth", ["sid" => $sid],  $session['id']);
+		}
+	}
+
+	public function endpoints($endpoint)
+	{
+		return [
+			"createAuth" => [
+				"type" => "post",
+				"urlHandler" => function() {
+					return "auth";
+				},
+				"responseHandler" => function($payload) {
+					return $payload['session']['sid'];
+				},
+				"payloadHandler" => function($data) {
+					return json_encode($data);
+				}
+			],
+			"getAuths" => [
+				"type" => "get",
+				"urlHandler" => function() {
+					return "auth/sessions";
+				},
+				"responseHandler" => function($payload) {
+					return $payload['sessions'];
+				}
+			],
+			"deleteAuth" => [
+				"type" => "delete",
+				"urlHandler" => function($id) {
+					return "auth/session/$id";
+				},
+			],
+			"get24HourStatsSummary" => [
+				"type" => "get",
+				"urlHandler" => function() {
+					$nowUnixTimestamp = time();
+					$oneDayAgoUnixTimestamp = $nowUnixTimestamp - (24 * 60 * 60);
+					return "stats/database/summary?from=$oneDayAgoUnixTimestamp&until=$nowUnixTimestamp";
+				}
+			],
+			"get24HourBlockedDomains" => [
+				"type" => "get",
+				"urlHandler" => function() {
+					$nowUnixTimestamp = time();
+					$oneDayAgoUnixTimestamp = $nowUnixTimestamp - (24 * 60 * 60);
+					return "stats/database/top_domains?from=$oneDayAgoUnixTimestamp&until=$nowUnixTimestamp&blocked=true&count=1000";
+				},
+				"responseHandler" => function($payload) {
+					return array_map(
+						function($item) {
+							return $item['domain'];
+						}, $payload['domains']
+					);
+					
+				}
+			],
+		][$endpoint];
+	}
+
+	public function doRequest($baseUrl, $endpoint, $headers = [], $data = null)
+	{
+		$endpointDictionary = $this->endpoints($endpoint);
+		$urlHandler = $endpointDictionary['urlHandler'];
+		$payloadHandler = $endpointDictionary['payloadHandler'] ?? function() {
+			return null;
+		};
+		$requestType = $endpointDictionary['type'];
+		$responseHandler = $endpointDictionary['responseHandler'] ?? function($payload) {
+			return $payload;
+		};
+		$url = $this->qualifyURL("$baseUrl/api/{$urlHandler($data)}");
+		$headers = $headers + ["User-Agent" => 'Organizr'];
+		try {
+			$response = Requests::$requestType($url, $headers, $payloadHandler($data));
+			
+			if ($response->success) {
+				$processedResponse = $responseHandler($this->testAndFormatString($response->body)["data"]);
+			}
+		} catch (Requests_Exception $e) {
+				$this->setResponse(500, $e->getMessage());
+				$this->setLoggerChannel('PiHole')->error($e);
+			};
+		return $processedResponse ?? [];
+	}
+
 	public function getPiholeHomepageStats()
 	{
 		if (!$this->homepageItemPermissions($this->piholeHomepagePermissions('main'), true)) {
@@ -128,24 +216,11 @@ trait PiHoleHomepageItem
 		$api = [];
 		$list = $this->csvHomepageUrlToken($this->config['piholeURL'], $this->config['piholeToken']);
 		foreach ($list as $key => $value) {
-			$url = $value['url'] . '/api.php?summaryRaw';
-			if ($value['token'] !== '' && $value['token'] !== null) {
-				$url = $url . '&auth=' . $value['token'];
-			}
-			try {
-				$response = Requests::get($url, [], []);
-				if ($response->success) {
-					@$piholeResults = json_decode($response->body, true);
-					if (is_array($piholeResults)) {
-						$ip = $this->qualifyURL($url, true)['host'];
-						$api['data'][$ip] = $piholeResults;
-					}
-				}
-			} catch (Requests_Exception $e) {
-				$this->setResponse(500, $e->getMessage());
-				$this->setLoggerChannel('PiHole')->error($e);
-				return false;
-			};
+			$base_url = $value['url'];
+			$sid = $this->getAuth($base_url, $value['token'])["sid"];
+			$stats = $this->doRequest($base_url, "get24HourStatsSummary", ["sid" => $sid]);
+			$stats["domains_being_blocked"] = $this->doRequest($base_url, "get24HourBlockedDomains", ["sid" => $sid]);
+			$api['data'][$base_url] = $stats;
 		}
 		$api['options']['combine'] = $this->config['homepagePiholeCombine'];
 		$api['options']['title'] = $this->config['piholeHeaderToggle'];

+ 1 - 1
api/homepage/plex.php

@@ -38,7 +38,7 @@ trait PlexHomepageItem
 					$this->settingsOption('disable-cert-check', 'plexDisableCertCheck'),
 					$this->settingsOption('use-custom-certificate', 'plexUseCustomCertificate'),
 					$this->settingsOption('token', 'plexToken'),
-					$this->settingsOption('button', '', ['label' => 'Get Plex Token', 'icon' => 'fa fa-ticket', 'text' => 'Retrieve', 'attr' => 'onclick="PlexOAuth(oAuthSuccess,oAuthError, null, \'#homepage-Plex-form [name=plexToken]\')"']),
+					$this->settingsOption('button', '', ['label' => 'Get Plex Token', 'icon' => 'fa fa-ticket', 'text' => 'Retrieve', 'attr' => 'onclick="PlexOAuth(oAuthSuccess,oAuthError, oAuthMaxRetry, null, null, \'#homepage-Plex-form [name=plexToken]\')"']),
 					$this->settingsOption('password-alt', 'plexID', ['label' => 'Plex Machine']),
 					$this->settingsOption('button', '', ['label' => 'Get Plex Machine', 'icon' => 'fa fa-id-badge', 'text' => 'Retrieve', 'attr' => 'onclick="showPlexMachineForm(\'#homepage-Plex-form [name=plexID]\')"']),
 				],

+ 117 - 0
api/homepage/prompage.php

@@ -0,0 +1,117 @@
+<?php
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+
+trait PromPageHomepageItem
+{
+	private static Client $kumaClient;
+
+	public function promPageSettingsArray($infoOnly = false)
+	{
+		$homepageInformation = [
+			'name' => 'PromPage',
+			'enabled' => true,
+			'image' => 'plugins/images/tabs/prompage.png',
+			'category' => 'Monitor',
+			'settingsArray' => __FUNCTION__
+		];
+		if ($infoOnly) {
+			return $homepageInformation;
+		}
+		$homepageSettings = [
+			'debug' => true,
+			'settings' => [
+				'Enable' => [
+					$this->settingsOption('html', null, ['override' => 6, 'label' => 'Info', 'html' => '<p>This homepage item requires <a href="https://github.com/henrywhitaker3/prompage" target="_blank" rel="noreferrer noopener">PromPage <i class="fa fa-external-link" aria-hidden="true"></i></a> to be running.</p>']),
+					$this->settingsOption('enable', 'homepagePromPageEnabled'),
+				],
+				'Connection' => [
+					$this->settingsOption('url', 'promPageURL', ['help' => 'URL for Uptime Kuma e.g. http://kuma:3001 (no trailing slash)', 'placeholder' => 'http://prompage:3000']),
+				],
+				'Options' => [
+					$this->settingsOption('refresh', 'homepagePromPageRefresh'),
+					$this->settingsOption('title', 'homepagePromPageHeader'),
+					$this->settingsOption('toggle-title', 'homepagePromPageHeaderToggle'),
+					$this->settingsOption('switch', 'homepagePromPageCompact', ['label' => 'Compact view', 'help' => 'Toggles the compact view of this homepage module']),
+					$this->settingsOption('switch', 'homepagePromPageShowUptime', ['label' => 'Show monitor uptime']),
+				],
+			]
+		];
+		return array_merge($homepageInformation, $homepageSettings);
+	}
+
+	public function promPageHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepagePromPageEnabled'
+				],
+				'not_empty' => [
+					'promPageURL',
+				]
+			]
+		];
+		return $this->homepageCheckKeyPermissions($key, $permissions);
+	}
+
+	public function homepageOrderPromPage()
+	{
+		if ($this->homepageItemPermissions($this->promPageHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Status Page...</h2></div>
+					<script>
+						// PromPage
+						homepagePromPage("' . $this->config['homepagePromPageRefresh'] . '");
+						// End PromPage
+					</script>
+				</div>
+				';
+		}
+	}
+
+	public function getpromPageHomepageData()
+	{
+		if (!$this->homepageItemPermissions($this->promPageHomepagePermissions('main'), true)) {
+			return false;
+		}
+		$api = [];
+		$url = $this->qualifyURL($this->config['promPageURL']);
+		try {
+			$services = json_decode($this->getPromPageClient($url, $this->config['promPageToken'])
+					->get('/api/services')
+					->getBody()
+					->getContents())->services;
+
+			$api = [
+				'data' => $services,
+				'options' => [
+					'title' => $this->config['homepagePromPageHeader'],
+					'titleToggle' => $this->config['homepagePromPageHeaderToggle'],
+					'compact' => $this->config['homepagePromPageCompact'],
+					'showUptime' => $this->config['homepagePromPageShowUptime'],
+				]
+			];
+		} catch (GuzzleException $e) {
+			$this->setLoggerChannel('promPage')->error($e);
+			$this->setAPIResponse('error', $e->getMessage(), 401);
+			return false;
+		};
+		$api = isset($api) ? $api : false;
+		$this->setAPIResponse('success', null, 200, $api);
+		return $api;
+	}
+
+	private function getPromPageClient(string $url): Client
+	{
+		if (!isset(static::$kumaClient)) {
+			static::$kumaClient = new Client([
+				'base_uri' => $url,
+			]);
+		}
+
+		return static::$kumaClient;
+	}
+}

+ 4 - 2
api/homepage/radarr.php

@@ -292,7 +292,9 @@ trait RadarrHomepageItem
 				$banner = "/plugins/images/homepage/no-np.png";
 				foreach ($child['images'] as $image) {
 					if ($image['coverType'] == "banner" || $image['coverType'] == "fanart") {
-						if (strpos($image['url'], '://') === false) {
+						if ($image['coverType'] == 'fanart' && (isset($image['remoteUrl']) && $image['remoteUrl'] !== '')) {
+							$banner = $image['remoteUrl'];
+						}elseif (strpos($image['url'], '://') === false) {
 							$imageUrl = $image['url'];
 							$urlParts = explode("/", $url);
 							$imageParts = explode("/", $image['url']);
@@ -306,7 +308,7 @@ trait RadarrHomepageItem
 						}
 					}
 				}
-				if ($banner !== "/plugins/images/homepage/no-np.png" || (strpos($banner, 'apikey') !== false)) {
+				if ($banner !== "/plugins/images/homepage/no-np.png" && (strpos($banner, 'apikey') !== false)) {
 					$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 					$imageURL = $banner;
 					$cacheFile = $cacheDirectory . $movieID . '.jpg';

+ 3 - 0
api/homepage/sonarr.php

@@ -282,6 +282,9 @@ trait SonarrHomepageItem
 				if ($image['coverType'] == "fanart" && (isset($image['url']) && $image['url'] !== '')) {
 					$fanArt = $image['url'];
 				}
+				if ($image['coverType'] == 'fanart' && (isset($image['remoteUrl']) && $image['remoteUrl'] !== '')) {
+					$fanArt = $image['remoteUrl'];
+				}
 			}
 			if ($fanArt !== "/plugins/images/homepage/no-np.png" || (strpos($fanArt, '://') === false)) {
 				$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;

+ 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">

+ 2 - 2
api/plugins/chat/plugin.php

@@ -36,7 +36,7 @@ class Chat extends Organizr
 										<li><i class="fa fa-chevron-right text-danger"></i> <a href="https://dashboard.pusher.com/accounts/sign_up" target="_blank"><span lang="en">Signup for Pusher [FREE]</span></a></li>
 										<li><i class="fa fa-chevron-right text-danger"></i> <span lang="en">Create an App called whatever you like and choose a cluster (Close to you)</span></li>
 										<li><i class="fa fa-chevron-right text-danger"></i> <span lang="en">Frontend (JQuery) - Backend (PHP)</span></li>
-										<li><i class="fa fa-chevron-right text-danger"></i> <span lang="en">Click the overview tab on top left</span></li>
+										<li><i class="fa fa-chevron-right text-danger"></i> <span lang="en">Click the App Keys tab on top left</span></li>
 										<li><i class="fa fa-chevron-right text-danger"></i> <span lang="en">Copy and paste the 4 values into Organizr</span></li>
 										<li><i class="fa fa-chevron-right text-danger"></i> <span lang="en">Save and reload!</span></li>
 									</ul>
@@ -176,4 +176,4 @@ class Chat extends Organizr
 		];
 		return $this->processQueries($response);
 	}
-}
+}

+ 1 - 1
api/plugins/invites/plugin.php

@@ -384,7 +384,7 @@ class Invites extends Organizr
 					'label' => 'Get Plex Token',
 					'icon' => 'fa fa-ticket',
 					'text' => 'Retrieve',
-					'attr' => 'onclick="PlexOAuth(oAuthSuccess,oAuthError, null, \'#INVITES-settings-items [name=plexToken]\')"'
+					'attr' => 'onclick="PlexOAuth(oAuthSuccess,oAuthError, oAuthMaxRetry, null, null, \'#INVITES-settings-items [name=plexToken]\')"'
 				),
 				array(
 					'type' => 'password-alt',

+ 13 - 0
api/v2/index.php

@@ -130,6 +130,19 @@ if (file_exists(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_S
 		}
 	}
 }
+/*
+ * Include Plugin routes from plugins/ directory (for external git repos)
+ */
+if (file_exists(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins')) {
+	$folder = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins';
+	$directoryIterator = new RecursiveDirectoryIterator($folder, FilesystemIterator::SKIP_DOTS);
+	$iteratorIterator = new RecursiveIteratorIterator($directoryIterator);
+	foreach ($iteratorIterator as $info) {
+		if ($info->getFilename() == 'routes.php' || ($info->getFilename() == 'api.php' && strpos($info->getPathname(), '/api/') !== false)) {
+			require_once $info->getPathname();
+		}
+	}
+}
 /*
  *
  *  This is the last defined api endpoint to catch all undefined endpoints

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

@@ -134,6 +134,16 @@ $app->post('/test/emby', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->post('/test/embyLiveTVTracker', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->testConnectionEmbyLiveTVTracker($Organizr->apiData($request));
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
 $app->post('/test/jellyfin', function ($request, $response, $args) {
 	/**
 	 * @OA\Post(
@@ -733,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']);
+});

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

@@ -316,6 +316,14 @@ $app->get('/homepage/kuma/data', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->get('/homepage/prompage/data', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getpromPageHomepageData();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
 $app->get('/homepage/speedtest/data', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
 	$Organizr->getSpeedtestHomepageData();
@@ -605,3 +613,38 @@ $app->post('/homepage/donate', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->get('/homepage/embyLiveTVTracker/stats', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getHomepageEmbyLiveTVStats();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/homepage/embyLiveTVTracker/activity', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getHomepageEmbyLiveTVActivity();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $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']);
+});

+ 1 - 1
api/v2/routes/plugins.php

@@ -66,4 +66,4 @@ $app->get('/plugins/marketplace', function ($request, $response, $args) {
 	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;
 }

文件差異過大導致無法顯示
+ 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,

文件差異過大導致無法顯示
+ 0 - 0
js/custom.min.js


文件差異過大導致無法顯示
+ 0 - 0
js/custom.min.js.bak


+ 282 - 68
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>
@@ -7872,6 +7899,23 @@ function buildPiholeItem(array){
     .inline-block {
         display: inline-block;
     }
+
+    ul.multi-column {
+        column-count: 1;
+        column-gap: 2em;
+    }
+
+    @media (min-width: 650px) {
+        ul.multi-column {
+            column-count: 3;
+        }
+    }
+
+    @media (min-width: 1200px) {
+        ul.multi-column {
+            column-count: 5;
+        }
+    }
     </style>
     `;
     var length = Object.keys(array['data']).length;
@@ -7879,11 +7923,11 @@ function buildPiholeItem(array){
     var totalQueries = function(data) {
 
         var card = `
-        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+        <div class="col-lg-4 col-md-6 col-sm-6 col-xs-12">
             <div class="card text-white mb-3 pihole-stat bg-green">
                 <div class="card-body">
                     <div class="inline-block">
-                        <p class="d-inline mr-1">Total queries</p>`;
+                        <p class="d-inline mr-1">Total queries (last 24 hours)</p>`;
         for(var key in data) {
             var e = data[key];
             if(typeof e['FTLnotrunning'] == 'undefined'){
@@ -7892,7 +7936,7 @@ function buildPiholeItem(array){
 	            }
 				let value = 'Error';
 				if(e.length == undefined){
-					value = e['dns_queries_today'].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+                    value = e['sum_queries'].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
 				}
 	            card += `<h3 data-toggle="tooltip" data-placement="right" title="`+key+`">`+value+`</h3>`;
 
@@ -7909,11 +7953,11 @@ function buildPiholeItem(array){
     };
     var totalBlocked = function(data) {
         var card = `
-        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+        <div class="col-lg-4 col-md-6 col-sm-6 col-xs-12">
             <div class="card bg-inverse text-white mb-3 pihole-stat bg-aqua">
                 <div class="card-body">
                     <div class="inline-block">
-                        <p class="d-inline mr-1">Queries Blocked</p>`;
+                        <p class="d-inline mr-1">Queries Blocked (last 24 hours)</p>`;
         for(var key in data) {
             var e = data[key];
 	        if(typeof e['FTLnotrunning'] == 'undefined') {
@@ -7922,7 +7966,7 @@ function buildPiholeItem(array){
 		        }
 		        let value = 'Error';
 		        if(e.length == undefined){
-			        value = e['ads_blocked_today'].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+                    value = e['sum_blocked'].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
 		        }
 		        card += `<h3 data-toggle="tooltip" data-placement="right" title="`+key+`">`+value+`</h3>`;
 			}
@@ -7938,11 +7982,11 @@ function buildPiholeItem(array){
     };
     var percentBlocked = function(data) {
         var card = `
-        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+        <div class="col-lg-4 col-md-6 col-sm-6 col-xs-12">
             <div class="card bg-inverse text-white mb-3 pihole-stat bg-yellow">
                 <div class="card-body">
                     <div class="inline-block">
-                        <p class="d-inline mr-1">Percent Blocked</p>`;
+                        <p class="d-inline mr-1">Percent Blocked (last 24 hours)</p>`;
         for(var key in data) {
             var e = data[key];
 	        if(typeof e['FTLnotrunning'] == 'undefined') {
@@ -7951,7 +7995,7 @@ function buildPiholeItem(array){
 		        }
 		        let value = 'Error';
 		        if(e.length == undefined){
-			        value = e['ads_percentage_today'].toFixed(1)
+                    value = e['percent_blocked'].toFixed(1)
 		        }
 		        card += `<h3 data-toggle="tooltip" data-placement="right" title="`+key+`">`+value+`</h3>`;
 	        }
@@ -7967,11 +8011,11 @@ function buildPiholeItem(array){
     };
     var domainsBlocked = function(data) {
         var card = `
-        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+        <div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
             <div class="card bg-inverse text-white mb-3 pihole-stat bg-red">
                 <div class="card-body">
                     <div class="inline-block">
-                        <p class="d-inline mr-1">Domains on Blocklist</p>`;
+                        <p class="d-inline mr-1">Domains on Blocklist (last 24 hours)</p>`;
         for(var key in data) {
             var e = data[key];
 	        if(typeof e['FTLnotrunning'] == 'undefined') {
@@ -7979,43 +8023,42 @@ function buildPiholeItem(array){
 			        card += `<p class="d-inline text-muted">(${key})</p>`;
 		        }
 		        let value = 'Error';
-		        if(e.length == undefined){
-			        value = e['domains_being_blocked'].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
-		        }
-		        card += `<h3 data-toggle="tooltip" data-placement="right" title="`+key+`">`+value+`</h3>`;
+                value = e['domains_being_blocked'].map(function (x) {
+                    return `<li>${x.toString()}</li>`;
+                }).join("");
+            }
+            card += `<ul class="multi-column" data-toggle="tooltip" title="` + key + `">` + value + `</ul>`;
 	        }
-        }
-        card += `
+            card += `
+                        </div>
+                        <i class="fa fa-list inline-block" aria-hidden="true"></i>
                     </div>
-                    <i class="fa fa-list inline-block" aria-hidden="true"></i>
                 </div>
             </div>
-        </div>
-        `
-        return card;
-    };
-
-	if(combine) {
-		stats += '<div class="row">'
-		stats += totalQueries(array['data']);
-		stats += totalBlocked(array['data']);
-		stats += percentBlocked(array['data']);
-		stats += domainsBlocked(array['data']);
-		stats += '</div>';
-	} else {
-		for(var key in array['data']) {
-			var data = array['data'][key];
-			obj = {};
-			obj[key] = data;
-			stats += '<div class="row">'
-			stats += totalQueries(obj);
-			stats += totalBlocked(obj);
-			stats += percentBlocked(obj);
-			stats += domainsBlocked(obj);
-			stats += '</div>';
-		};
-	}
+            `
+            return card;
+        }
 
+    if(combine) {
+        stats += '<div class="row">'
+        stats += totalQueries(array['data']);
+        stats += totalBlocked(array['data']);
+        stats += percentBlocked(array['data']);
+        stats += domainsBlocked(array['data']);
+        stats += '</div>';
+    } else {
+        for(var key in array['data']) {
+            var data = array['data'][key];
+            obj = {};
+            obj[key] = data;
+            stats += '<div class="row">'
+            stats += totalQueries(obj);
+            stats += totalBlocked(obj);
+            stats += percentBlocked(obj);
+            stats += domainsBlocked(obj);
+            stats += '</div>';
+        }
+    }
     return stats;
 }
 function homepagePihole(timeout){
@@ -9206,6 +9249,121 @@ function homepageUptimeKuma(timeout){
     timeouts[timeoutTitle] = setTimeout(function(){ homepageUptimeKuma(timeout); }, timeout);
     delete timeout;
 }
+function buildPromPageItem(array) {
+    var cards = '';
+    var options = array['options'];
+    var services = array['data'];
+    var tabName = '';
+    console.log(options)
+
+    var buildCard = function(name, data) {
+        if(data.status == true) {
+            var statusColor = 'success'; var imageText = 'fa fa-check-circle text-success'
+        } else {
+            var statusColor = 'danger animated-3 loop-animation flash'; var imageText = 'fa fa-times-circle text-danger'
+        }
+        tabName = data.name;
+        if(options['compact']) {
+            var card = `
+            <div class="col-xl-2 col-lg-3 col-md-4 col-sm-6 col-xs-12">
+                <div class="card bg-inverse text-white mb-3 monitorr-card">
+                    <div class="card-body bg-org-alt pt-1 pb-1">
+                        <div class="d-flex no-block align-items-center">
+                            <div class="left-health bg-`+statusColor+`"></div>
+                            <div class="ml-1 w-100">
+                                <i class="`+imageText+` font-20 pull-right mt-3 mb-2"></i>
+                                `;
+                                card += `<h3 class="d-flex no-block align-items-center mt-2 mb-2"><img class="lazyload loginTitle">&nbsp;`+data.name;
+                                if (data.uptime != null && options.showUptime) {
+                                    card += `<span class="ml-3 font-12 align-self-center text-dark">`+ Math.round(data.uptime * 100) / 100 +`%</span></h3>`
+                                }
+                                card += `</h3>`
+                                card += `<div class="clearfix"></div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>`;
+        } else {
+            var card = `
+            <div class="col-lg-2 col-md-3 col-sm-4 col-xs-6">
+                <div class="card bg-inverse text-white mb-3 monitorr-card">
+                    <div class="card-body bg-org-alt text-center">
+                        `;
+                        card += `<div class="d-block">
+                            <h3 class="mt-0 mb-2">`+data.name+`</h3>`
+                            
+                        if (data.uptime != null && options.showUptime) {
+                            card += `<p class="text-dark mb-0">`+ Math.round(data.uptime * 100) / 100 +`%</p>`
+                        }
+
+                        card += `</div>
+                        <div class="d-inline-block mt-4 py-2 px-4 badge indicator bg-`+statusColor+`">
+                            <p class="mb-0">`; if(data.status == true) { card += 'UP' } else { card += 'DOWN' } card+=`</p>
+                        </div>
+                        `;
+                        card += `</div>
+                </div>
+            </div>
+            `;
+        }
+        return card;
+    }
+    for(var key in services) {
+        cards += buildCard(key, services[key]);
+    };
+    return cards;
+}
+function buildPromPage(array) {
+    if(array === false){ return ''; }
+    if(array.error != undefined) {
+	    organizrConsole('PromPage Function',array.error, 'error');
+    } else {
+        var html = `
+        <div id="allPromPage">
+            <div class="el-element-overlay row">`
+        if(array['options']['titleToggle']) {
+            html += `
+                <div class="col-md-12">
+                    <h4 class="pull-left homepage-element-title"><span lang="en">`+array['options']['title']+`</span> : </h4>
+                    <hr class="hidden-xs ml-2">
+                </div>
+                <div class="clearfix"></div>
+            `;
+        }
+        html += `
+                <div class="promPageCards">
+                    `+buildPromPageItem(array)+`
+                </div>
+            </div>
+        </div>
+        <div class="clearfix"></div>
+        `;
+    }
+    return (array) ? html : '';
+}
+function homepagePromPage(timeout){
+    var timeout = (typeof timeout !== 'undefined') ? timeout : activeInfo.settings.homepage.refresh.homepagePromPageRefresh;
+    organizrAPI2('GET','api/v2/homepage/prompage/data').success(function(data) {
+        try {
+            let response = data.response;
+	        document.getElementById('homepageOrderPromPage').innerHTML = '';
+	        if(response.data !== null){
+		        buildUptimeKuma(response.data)
+		        $('#homepageOrderPromPage').html(buildPromPage(response.data));
+	        }
+        }catch(e) {
+            console.log(e)
+	        organizrCatchError(e,data);
+        }
+    }).fail(function(xhr) {
+	    OrganizrApiError(xhr);
+    });
+    let timeoutTitle = 'PromPage-Homepage';
+    if(typeof timeouts[timeoutTitle] !== 'undefined'){ clearTimeout(timeouts[timeoutTitle]); }
+    timeouts[timeoutTitle] = setTimeout(function(){ homepagePromPage(timeout); }, timeout);
+    delete timeout;
+}
 function homepageSpeedtest(timeout){
     var timeout = (typeof timeout !== 'undefined') ? timeout : activeInfo.settings.homepage.refresh.homepageSpeedtestRefresh;
     organizrAPI2('GET','api/v2/homepage/speedtest/data').success(function(data) {
@@ -10470,9 +10628,9 @@ getPlexOAuthPin = function () {
     return deferred;
 };
 var polling = null;
-function PlexOAuth(success, error, pre, id = null) {
-    if (typeof pre === "function") {
-        pre()
+function PlexOAuth(successCallback, errorCallback, maxRetryCallback, pollingCallback, preFunction, clientID = null) {
+    if (typeof preFunction === "function") {
+        preFunction()
     }
     closePlexOAuthWindow();
     plex_oauth_window = PopupCenter('', 'Plex-OAuth', 600, 700);
@@ -10496,39 +10654,42 @@ function PlexOAuth(success, error, pre, id = null) {
         };
         plex_oauth_window.location = 'https://app.plex.tv/auth/#!?' + encodeData(oauth_params);
         polling = pin;
+        let maxPollCount = 120;
         (function poll() {
+            maxPollCount--;
             $.ajax({
                 url: 'https://plex.tv/api/v2/pins/' + pin,
                 type: 'GET',
                 headers: x_plex_headers,
                 success: function (data) {
                     if (data.authToken){
+                        polling = null;
                         closePlexOAuthWindow();
-                        if (typeof success === "function") {
-                            success('plex',data.authToken, id)
+                        if (typeof successCallback === "function") {
+                            successCallback('plex', data.authToken, clientID)
                         }
                     }
                 },
-                error: function (jqXHR, textStatus, errorThrown) {
-                    if (textStatus !== "timeout") {
+                complete: function () {
+                    if (maxPollCount <= 0) {
                         closePlexOAuthWindow();
-                        if (typeof error === "function") {
-                            error()
+                        if (typeof maxRetryCallback === "function") {
+                            maxRetryCallback()
                         }
-                    }
-                },
-                complete: function () {
-                    if (!plex_oauth_window.closed && polling === pin){
+                    } else if (polling === pin) {
                         setTimeout(function() {poll()}, 1000);
+                        if (typeof pollingCallback === "function") {
+                            pollingCallback(maxPollCount);
+                        }
                     }
                 },
-                timeout: 10000
+                timeout: 1000
             });
         })();
     }, function () {
         closePlexOAuthWindow();
-        if (typeof error === "function") {
-            error()
+        if (typeof errorCallback === "function") {
+            errorCallback()
         }
     });
 }
@@ -10567,10 +10728,13 @@ function oAuthSuccess(type,token, id = null){
 function oAuthError(){
     messageSingle('',window.lang.translate('Error Connecting to oAuth Provider'),activeInfo.settings.notifications.position,'#FFF','error','5000');
 }
+function oAuthMaxRetry(){
+    messageSingle('',window.lang.translate('Max Retry Error Connecting to oAuth Provider'),activeInfo.settings.notifications.position,'#FFF','error','5000');
+}
 function oAuthStart(type){
     switch(type){
         case 'plex':
-            PlexOAuth(oAuthSuccess,oAuthError);
+            PlexOAuth(oAuthSuccess,oAuthError, oAuthMaxRetry, null, null);
             break;
         default:
             break;
@@ -10638,7 +10802,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);
@@ -10648,9 +10813,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",
@@ -11952,7 +12121,19 @@ function showPlexMachineForm(selector = null){
 		})
 	);
 }
-function oAuthLoginNeededCheck() {
+function bypassLocalLogin() {
+
+	if(activeInfo.settings.user.bypass !== true){
+		return false;
+	}
+	const bypass = $.urlParam('bypassDisable');
+	if(bypass){
+		return false;
+	}
+	OAuthLoginNeeded = true;
+	oAuthLoginNeededCheck('Bypass');
+}
+function oAuthLoginNeededCheck(type = "OAuth") {
     if(OAuthLoginNeeded == false){
         return false;
     }else{
@@ -11960,8 +12141,15 @@ function oAuthLoginNeededCheck() {
             return false;
         }
     }
-    message('OAuth', ' Proceeding to login', activeInfo.settings.notifications.position, '#FFF', 'info', '10000');
-    organizrAPI2('POST', 'api/v2/login', '').success(function (data) {
+	let data = '';
+	if(type === 'Bypass'){
+		const bypass = $.urlParam('bypassDisable');
+		if(bypass){
+			data = 'bypass';
+		}
+	}
+    message(type, ' Proceeding to login', activeInfo.settings.notifications.position, '#FFF', 'info', '10000');
+    organizrAPI2('POST', 'api/v2/login', data).success(function (data) {
 	    local('set','message','Welcome|Login Successful|success');
 	    local('r','loggingIn');
 	    location.reload();
@@ -12009,6 +12197,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>' : '';
@@ -12505,6 +12718,7 @@ function launch(){
 	        }
 	        console.info("%c Organizr %c ".concat("DOM Fully loaded", " "), "color: white; background: #AD80FD; font-weight: 700;", "color: #AD80FD; background: white; font-weight: 700;");
 	        oAuthLoginNeededCheck();
+			bypassLocalLogin();
         } catch (e) {
             orgErrorCode(data);
             defineNotification();

+ 511 - 302
js/langpack/ko[Korean].json

@@ -16,7 +16,7 @@
         "Email": "이메일",
         "Enter your Email and instructions will be sent to you!": "이메일을 입력하시면 지침이 전송됩니다!",
         "Register": "등록",
-        "Registration Password": "암호 등록",
+        "Registration Password": "등록 암호",
         "Recover Password": "암호 복구",
         "Password": "암호",
         "Sign Up": "가입",
@@ -27,7 +27,7 @@
         "Installed": "설치됨",
         "Install Update": "업데이트 설치",
         "Organizr Versions": "Organizr 버전",
-        "About": "Organizr에 대하여",
+        "About": "관련 정보",
         "Organizr Logs": "Organizr 로그",
         "Main Settings": "메인 설정",
         "Updates": "업데이트",
@@ -51,9 +51,9 @@
         "Organizr Log": "Organizr 로그",
         "FIXED": "고침",
         "NEW": "신규",
-        "NOTE": "유의",
+        "NOTE": "참고",
         "Update Available": "업데이트 있음",
-        "is available, goto": "가 있습니다. 바로 가기:",
+        "is available, goto": "이() 있습니다. 바로 가기:",
         "Update Tab": "업데이트",
         "Database Name:": "데이터베이스 이름:",
         "Database Name": "데이터베이스 이름",
@@ -62,7 +62,7 @@
         "Hover to show": "마우스를 올려 표시",
         "API Key:": "API 키:",
         "API Key": "API 키",
-        "Registration Password:": "암호 등록:",
+        "Registration Password:": "등록 암호:",
         "Hash Key:": "해시 키:",
         "Hash Key": "해시 키",
         "Password:": "암호:",
@@ -70,8 +70,8 @@
         "The Hash Key will be used to decrypt all passwords etc... on the server.": "해시 키는 서버의 모든 암호 등을 해독하는 데 사용됩니다.",
         "The API Key will be used for all calls to organizr for the UI. [Auto-Generated]": "API 키는 UI용 Organizr에 대한 모든 호출에 사용됩니다. [자동 생성됨]",
         "Notice": "공지",
-        "Business": "비지니스",
-        "Personal": "프로패셔널",
+        "Business": "기업용",
+        "Personal": "개인용",
         "Choose License": "라이선스 선택",
         "Install Type": "설치 유형",
         "Verify": "검증",
@@ -98,11 +98,12 @@
         "Information": "정보",
         "Below you will find all the links for everything that has to do with Organizr": "아래에서 Organizr와 관련된 모든 것에 대한 모든 링크를 찾을 수 있습니다.",
         "Loading...": "로드 중...",
+        "Loading": "로드 중",
         "Donate": "기부",
         "Edit Group": "그룹 편집",
         "For icons, use the following format:": "아이콘의 경우 다음 형식을 사용합니다:",
         "For images, use the following format:": "이미지의 경우 다음 형식을 사용합니다:",
-        "You may use an image or icon in this field": "You may use an image or icon in this field",
+        "You may use an image or icon in this field": "이 필드에는 이미지나 아이콘을 사용할 수 있습니다",
         "Image Legend": "이미지 범례",
         "Category Image": "카테고리 이미지",
         "Category Name": "카테고리 이름",
@@ -121,7 +122,7 @@
         "Edit Tab": "탭 편집",
         "Add Tab": "탭 추가",
         "Add New Tab": "신규 탭 추가",
-        "SPLASH": "스래시",
+        "SPLASH": "스래시",
         "ACTIVE": "활성화",
         "TYPE": "유형",
         "GROUP": "그룹",
@@ -181,8 +182,8 @@
         "Inactive Plugins": "플러그인 비활성화",
         "Active Plugins": "플러그인 활성화",
         "Inactive": "비활성화",
-        "Everything Active": "모든 활성화",
-        "Nothing Active": "활성화되지 않음",
+        "Everything Active": "모두 활성화되어 있음",
+        "Nothing Active": "활성화된 플러그인 없음",
         "Choose Plex Machine": "Plex 머신 선택",
         "Test Speed to Server": "서버에 속도 테스트하기",
         "Test Server Speed": "서버 속도 테스트",
@@ -239,14 +240,15 @@
         "Loading Now Playing...": "지금 재생 로드 중...",
         "Loading Playlists...": "재생 목록 로드 중...",
         "Loading Download Queue...": "다운로드 대기열 로드 중...",
+        "Loading Bookmarks...": "북마크 로드 중...",
         "Organizr Mod Picks": "Organizr 모드 추천",
         "Requests": "요청",
-        "Become Sponsor": "후원자가 되세요",
+        "Become Sponsor": "스폰서가 되세요",
         "Splash Page": "스플래시 페이지",
         "Lock Screen": "화면 잠금",
-        "If you signed in with a Emby Acct... Please use the following link to change your password there:": "If you signed in with a Emby Acct... Please use the following link to change your password there:",
+        "If you signed in with a Emby Acct... Please use the following link to change your password there:": "Emby 계정으로 로그인한 경우... 다음 링크를 사용하여 암호를 변경하세요:",
         "Password Notice": "암호 공지",
-        "If you signed in with a Plex Acct... Please use the following link to change your password there:": "If you signed in with a Plex Acct... Please use the following link to change your password there:",
+        "If you signed in with a Plex Acct... Please use the following link to change your password there:": "Plex 계정으로 로그인하신 경우... 아래 링크를 사용하여 암호를 변경하세요:",
         "Active Tokens": "활성 토큰",
         "Deactivate": "비활성화",
         "Activate": "활성화",
@@ -259,18 +261,18 @@
         "Chat": "채팅",
         "Current Directory: ": "현재 디렉토리: ",
         "Suggested Directory: ": "추천 디렉토리: ",
-        "The Registration Password will lockout the registration field with this password. {User-Generated]": "The Registration Password will lockout the registration field with this password. {User-Generated]",
-        "The Hash Key will be used to decrypt all passwords etc... on the server. {User-Generated]": "The Hash Key will be used to decrypt all passwords etc... on the server. {User-Generated]",
-        "If using Plex or Emby - It is suggested that you use the username and email of the Admin account.": "If using Plex or Emby - It is suggested that you use the username and email of the Admin account.",
-        "Business has Media items hidden [Plex, Emby etc...]": "Business has Media items hidden [Plex, Emby etc...]",
-        "Personal has everything unlocked - no restrictions": "Personal has everything unlocked - no restrictions",
+        "The Registration Password will lockout the registration field with this password. {User-Generated]": "등록 암호는 이 암호로 등록 필드를 잠급니다. {사용자 생성됨]",
+        "The Hash Key will be used to decrypt all passwords etc... on the server. {User-Generated]": "해시 키는 서버의 모든 암호 등을 해독하는 데 사용됩니다. {사용자 생성됨]",
+        "If using Plex or Emby - It is suggested that you use the username and email of the Admin account.": "Plex 혹은 Emby를 사용하는 경우 - 관리자 계정의 사용자 이름과 이메일을 사용하는 것이 좋습니다.",
+        "Business has Media items hidden [Plex, Emby etc...]": "기업용은 미디어 항목이 숨겨집니다 [Plex, Emby 등...]",
+        "Personal has everything unlocked - no restrictions": "개인용은 모든 것이 잠금 해제되어 있습니다 - 제한이 없습니다",
         "Continue To Website": "웹사이트로 계속",
         "Patreon": "Patreon",
         "Cryptos": "Cryptos",
         "Square Cash": "Square Cash",
         "PayPal": "PayPal",
         "Beerpay.io": "Beerpay.io",
-        "Sponsors": "후원자",
+        "Sponsors": "스폰서",
         "THEME": "테마",
         "Theme Marketplace": "테마 스토어",
         "Test Tab": "탭 테스트",
@@ -278,7 +280,7 @@
         "Choose Icon": "아이콘 선택",
         "Choose Image": "이미지 선택",
         "Ping URL": "URL 핑",
-        "Please set tab as [New Window] on next screen": "다음 화면에서 탭을 [새 창]으로 설정해주세요",
+        "Please set tab as [New Window] on next screen": "다음 화면에서 탭을 [새 창(New Window)]으로 설정해주세요",
         "Tab can be set as iFrame": "탭을 iFrame으로 설정할 수 있습니다",
         "Premier": "프리미어",
         "Missing": "누락",
@@ -290,8 +292,8 @@
         "Choose Media Type": "미디어 유형 선택",
         "PHP Version Check": "PHP 버전 확인",
         "Don\\'t have an account?": "계정이 없습니까?",
-        "The value of #987654 is just a placeholder, you can change to any value you like.": "The value of #987654 is just a placeholder, you can change to any value you like.",
-        "This is not the same as database authentication - i.e. Plex Authentication | Emby Authentication | FTP Authentication": "This is not the same as database authentication - i.e. Plex Authentication | Emby Authentication | FTP Authentication",
+        "The value of #987654 is just a placeholder, you can change to any value you like.": "#987654의 값은 자리 표시자일 뿐이므로 원하는 값으로 변경할 수 있습니다.",
+        "This is not the same as database authentication - i.e. Plex Authentication | Emby Authentication | FTP Authentication": "이는 데이터베이스 인증과 동일하지 않습니다 - 예: Plex 인증 | Emby 인증 | FTP 인증",
         "Status: [ ": "상태: [ ",
         "This module requires XMLRPC": "이 모듈에는 XMLRPC가 필요합니다",
         "Misc Options": "기타 옵션",
@@ -313,18 +315,18 @@
         "Start": "시작",
         "Everyone Refresh Seconds": "모든 사용자 새로 고침 시간(초)",
         "Admin Refresh Seconds": "관리자 새로 고침 시간(초)",
-        "Minimum Authentication for Time Display": "Minimum Authentication for Time Display",
+        "Minimum Authentication for Time Display": "시간 표시를 위한 최소 인증",
         "Show Ping Time": "핑 시간 보여주기",
         "Offline Sound": "오프라인 소리",
         "Online Sound": "온라인 소리",
-        "Minimum Authentication for Message and Sound": "Minimum Authentication for Message and Sound",
+        "Minimum Authentication for Message and Sound": "메시지 및 소리에 대한 최소 인증",
         "Minimum Authentication": "최소 인증",
         "Nginx Auth Debug": "Nginx 인증 디버그",
         "Hide Registration": "등록 숨기기",
-        "Lockout Groups To": "Lockout Groups To",
-        "Lockout Groups From": "Lockout Groups From",
-        "Inactivity Lock": "Inactivity Lock",
-        "Inactivity Timer [Minutes]": "Inactivity Timer [Minutes]",
+        "Lockout Groups To": "잠금 그룹 대상",
+        "Lockout Groups From": "잠금 그룹 위치",
+        "Inactivity Lock": "비활동 잠금",
+        "Inactivity Timer [Minutes]": "비활동 시간 [분]",
         "Emby Token": "Emby 토큰",
         "http(s)://hostname:port": "http(s)://hostname:port",
         "Emby URL": "Emby URL",
@@ -351,10 +353,10 @@
         "Plex Note": "Plex 참고",
         "Admin username for Plex": "Plex 관리자 사용자 이름",
         "Admin Username": "관리자 사용자 이름",
-        "Click Main on the sub-menu above.": "Click Main on the sub-menu above.",
-        "Important Information": "Important Information",
-        "PING": "PING",
-        "Custom HTML/JavaScript": "Custom HTML/JavaScript",
+        "Click Main on the sub-menu above.": "인증을 사용자 지정하려면 위의 하위 메뉴에서 메인을 클릭하세요.",
+        "Important Information": "중요한 정보",
+        "PING": "",
+        "Custom HTML/JavaScript": "사용자 지정 HTML/JavaScript",
         "CustomHTML-2": "CustomHTML-2",
         "CustomHTML-1": "CustomHTML-1",
         "Refresh Seconds": "새로 고침 시간(초)",
@@ -363,10 +365,10 @@
         "Token": "토큰",
         "URL": "URL",
         "Ombi": "Ombi",
-        "Items Per Day": "Items Per Day",
-        "Time Format": "Time Format",
-        "Default View": "Default View",
-        "Start Day": "Start Day",
+        "Items Per Day": "일일 항목",
+        "Time Format": "시간 형식",
+        "Default View": "기본 보기",
+        "Start Day": "시작일",
         "SickRage": "SickRage",
         "CouchPotato": "CouchPotato",
         "Test Connection": "연결 테스트",
@@ -377,11 +379,11 @@
         "Lidarr": "Lidarr",
         "Show Unmonitored": "모니터링되지 않은 항목 보여주기",
         "Sonarr": "Sonarr",
-        "Hide Completed": "Hide Completed",
-        "Hide Seeding": "Hide Seeding",
+        "Hide Completed": "완료된 항목 숨기기",
+        "Hide Seeding": "시딩 중인 항목 숨기기",
         "Deluge": "Deluge",
-        "Order": "Order",
-        "Status: [": "Status: [",
+        "Order": "순서",
+        "Status: [": "상태: [",
         "]": "]",
         "rTorrent": "rTorrent",
         "Reverse Sorting": "Reverse Sorting",
@@ -389,238 +391,238 @@
         "Transmission": "Transmission",
         "NZBGet": "NZBGet",
         "SabNZBD": "SabNZBD",
-        "Image Cache Size": "Image Cache Size",
-        "Emby Tab WAN URL": "Emby Tab WAN URL",
-        "Only use if you have Emby in a reverse proxy": "Only use if you have Emby in a reverse proxy",
-        "Emby Tab Name": "Emby Tab Name",
-        "Item Limit": "Item Limit",
-        "Minimum Authorization": "Minimum Authorization",
-        "User Information": "User Information",
+        "Image Cache Size": "이미지 캐시 크기",
+        "Emby Tab WAN URL": "Emby  WAN URL",
+        "Only use if you have Emby in a reverse proxy": "역방향 프록시에 Emby가 있는 경우에만 사용하세요",
+        "Emby Tab Name": "Emby 탭 이름",
+        "Item Limit": "항목 제한",
+        "Minimum Authorization": "최소 인증",
+        "User Information": "사용자 정보",
         "Emby": "Emby",
-        "Plex Tab WAN URL": "Plex Tab WAN URL",
-        "Only use if you have Plex in a reverse proxy": "Only use if you have Plex in a reverse proxy",
-        "Plex Tab Name": "Plex Tab Name",
-        "Media Server": "Media Server",
+        "Plex Tab WAN URL": "Plex  WAN URL",
+        "Only use if you have Plex in a reverse proxy": "역방향 프록시에 Plex가 있는 경우에만 사용하세요",
+        "Plex Tab Name": "Plex 탭 이름",
+        "Media Server": "미디어 서버",
         "Plex": "Plex",
-        "separate by comma's": "separate by comma's",
-        "iCal URL's": "iCal URL's",
-        "Enable iCal": "Enable iCal",
-        "Calendar": "Calendar",
-        "Theme Javascript": "Theme Javascript",
-        "Custom Javascript": "Custom Javascript",
-        "Theme CSS [Can replace colors from above]": "Theme CSS [Can replace colors from above]",
-        "Custom CSS [Can replace colors from above]": "Custom CSS [Can replace colors from above]",
-        "Copy code and paste inside left box": "Copy code and paste inside left box",
-        "Download and unzip file and place in": "Download and unzip file and place in",
-        "Click [Generate your Favicons and HTML code]": "Click [Generate your Favicons and HTML code]",
-        "Enter this path": "Enter this path",
-        "At bottom of page on [Favicon Generator Options] under [Path] choose [I cannot or I do not want to place favicon files at the root of my web site.]": "At bottom of page on [Favicon Generator Options] under [Path] choose [I cannot or I do not want to place favicon files at the root of my web site.]",
-        "Edit settings to your liking": "Edit settings to your liking",
-        "Choose your image to use": "Choose your image to use",
-        "Click [Select your Favicon picture]": "Click [Select your Favicon picture]",
-        "Instructions": "Instructions",
-        "Fav Icon Code": "Fav Icon Code",
-        "Test Message": "Test Message",
-        "Position": "Position",
-        "Style": "Style",
-        "Theme": "Theme",
-        "Button Text Color": "Button Text Color",
-        "Button Color": "Button Color",
-        "Accent Text Color": "Accent Text Color",
-        "Accent Color": "Accent Color",
-        "Side Bar Text Color": "Side Bar Text Color",
-        "Side Bar Color": "Side Bar Color",
-        "Nav Bar Text Color": "Nav Bar Text Color",
-        "Nav Bar Color": "Nav Bar Color",
-        "Unsorted Tab Placement": "Unsorted Tab Placement",
-        "Alternate Homepage Titles": "Alternate Homepage Titles",
-        "Minimal Login Screen": "Minimal Login Screen",
-        "Login Wallpaper": "Login Wallpaper",
-        "Use Logo instead of Title": "Use Logo instead of Title",
-        "Title": "Title",
-        "Logo": "Logo",
-        "Import Users": "Import Users",
-        "Drop files here to upload": "Drop files here to upload",
-        "SpeedTest Settings": "SpeedTest Settings",
-        "PHP Mailer Settings": "PHP Mailer Settings",
-        "Invites Settings": "Invites Settings",
-        "Chat Settings": "Chat Settings",
-        "Delete ": "Delete ",
-        "App Cluster": "App Cluster",
+        "separate by comma's": "쉼표로 구분",
+        "iCal URL's": "iCal URL",
+        "Enable iCal": "iCal 활성화",
+        "Calendar": "캘린더",
+        "Theme Javascript": "테마 자바스크립트",
+        "Custom Javascript": "사용자 지정 자바스크립트",
+        "Theme CSS [Can replace colors from above]": "테마 CSS [위에서 색상 교체 가능]",
+        "Custom CSS [Can replace colors from above]": "사용자 지정 CSS [위에서 색상 교체 가능]",
+        "Copy code and paste inside left box": "코드를 복사하여 왼쪽 상자 안에 붙여넣으세요",
+        "Download and unzip file and place in": "파일을 다운로드하고 압축을 풀고 다음 위치에 넣으세요:",
+        "Click [Generate your Favicons and HTML code]": "[파비콘 및 HTML 코드 생성(Select your Favicon picture)]을 클릭하세요",
+        "Enter this path": "다음 경로로 이동하세요:",
+        "At bottom of page on [Favicon Generator Options] under [Path] choose [I cannot or I do not want to place favicon files at the root of my web site.]": "페이지 하단의 [경로(Path)] 아래 [파비콘 생성기 옵션(Favicon Generator Options)]에서 [파비콘 파일을 내 웹 사이트 루트에 넣을 수 없거나 넣고 싶지 않습니다.(I cannot or I do not want to place favicon files at the root of my web site.)]을 선택하세요.",
+        "Edit settings to your liking": "원하는 대로 설정을 편집하세요",
+        "Choose your image to use": "사용할 이미지를 선택하세요",
+        "Click [Select your Favicon picture]": "[파비콘 사진 선택(Select your Favicon picture)]을 클릭하세요",
+        "Instructions": "지침",
+        "Fav Icon Code": "파비콘 코드",
+        "Test Message": "메시지 테스트",
+        "Position": "위치",
+        "Style": "스타일",
+        "Theme": "테마",
+        "Button Text Color": "버튼 텍스트 색상",
+        "Button Color": "버튼 색상",
+        "Accent Text Color": "강조 텍스트 색상",
+        "Accent Color": "강조 색상",
+        "Side Bar Text Color": "사이드 바 텍스트 색상",
+        "Side Bar Color": "사이드 바 색상",
+        "Nav Bar Text Color": "내비 바 텍스트 색상",
+        "Nav Bar Color": "내비 바 색상",
+        "Unsorted Tab Placement": "정렬 안 된 탭 배치",
+        "Alternate Homepage Titles": "대체 홈페이지 제목",
+        "Minimal Login Screen": "최소 로그인 화면",
+        "Login Wallpaper": "로그인 배경 화면",
+        "Use Logo instead of Title": "제목 대신 로고 사용",
+        "Title": "제목",
+        "Logo": "로고",
+        "Import Users": "사용자 가져오기",
+        "Drop files here to upload": "업로드할 파일을 여기에 드롭하세요",
+        "SpeedTest Settings": "SpeedTest 설정",
+        "PHP Mailer Settings": "PHP Mailer 설정",
+        "Invites Settings": "초대 설정",
+        "Chat Settings": "채팅 설정",
+        "Delete ": "삭제: ",
+        "App Cluster": "앱 클러스터",
         "App ID": "App ID",
-        "API Secret": "API Secret",
-        "Auth Key": "Auth Key",
-        "Use Pusher SSL": "Use Pusher SSL",
-        "Message Sound": "Message Sound",
-        "# of Previous Messages": "# of Previous Messages",
-        "Note": "Note",
-        "Libraries": "Libraries",
-        "Body": "Body",
-        "Name": "Name",
-        "Template #4": "Template #4",
-        "Template #3": "Template #3",
-        "Template #2": "Template #2",
-        "Reminder Template": "Reminder Template",
-        "Invite User": "Invite User",
-        "Reset Password": "Reset Password",
-        "New Registration": "New Registration",
-        "Edit Template": "Edit Template",
-        "Send Welcome E-Mail": "Send Welcome E-Mail",
-        "Full URL": "Full URL",
-        "WAN Logo URL": "WAN Logo URL",
+        "API Secret": "API 비밀",
+        "Auth Key": "인증 키",
+        "Use Pusher SSL": "Pusher SSL 사용",
+        "Message Sound": "메시지 소리",
+        "# of Previous Messages": "이전 메시지 수",
+        "Note": "참고",
+        "Libraries": "라이브러리",
+        "Body": "몸체",
+        "Name": "이름",
+        "Template #4": "템플릿 #4",
+        "Template #3": "템플릿 #3",
+        "Template #2": "템플릿 #2",
+        "Reminder Template": "리마인더 템플릿",
+        "Invite User": "사용자 초대",
+        "Reset Password": "암호 재설정",
+        "New Registration": "신규 등록",
+        "Edit Template": "템플릿 편집",
+        "Send Welcome E-Mail": "환영 이메일 보내기",
+        "Full URL": "전체 URL",
+        "WAN Logo URL": "WAN 로고 URL",
         "https://domain.com/": "https://domain.com/",
-        "Domain Link Override": "Domain Link Override",
-        "Send": "Send",
-        "Send Test": "Send Test",
-        "i.e. same as username": "i.e. same as username",
-        "Sender Email": "Sender Email",
-        "Sender Name": "Sender Name",
-        "Verify Certificate": "Verify Certificate",
-        "Authentication": "Authentication",
-        "SMTP Port": "SMTP Port",
-        "SMTP Host": "SMTP Host",
-        "Results For cmd:": "Result For cmd:",
-        "Organizr Information:": "Organizr Information:",
-        "DB Schema": "DB Schema",
-        "Misc SSO": "Misc SSO",
+        "Domain Link Override": "도메인 링크 재정의",
+        "Send": "보내기",
+        "Send Test": "보내기 테스트",
+        "i.e. same as username": "예: 사용자 이름과 동일",
+        "Sender Email": "보낸 사람 이메일",
+        "Sender Name": "보낸 사람 이름",
+        "Verify Certificate": "인증서 확인",
+        "Authentication": "인증",
+        "SMTP Port": "SMTP 포트",
+        "SMTP Host": "SMTP 호스트",
+        "Results For cmd:": "cmd에서의 결과:",
+        "Organizr Information:": "Organizr 정보:",
+        "DB Schema": "DB 스키마",
+        "Misc SSO": "기타 SSO",
         "Tautulli SSO": "Tautulli SSO",
         "Plex SSO": "Plex SSO",
         "Ombi SSO": "Ombi SSO",
-        "Commands": "Commands",
-        "Input Command": "Input Command",
-        "Organizr Debug Area": "Organizr Debug Area",
+        "Commands": "명령",
+        "Input Command": "입력 명령",
+        "Organizr Debug Area": "Organizr 디버그 영역",
         "Google Ads": "Google Ads",
-        "DB Folder": "DB Folder",
-        "API Folder": "API Folder",
-        "Root Folder": "Root Folder",
-        "Organizr Paths": "Organizr Paths",
-        "Organizr News": "Organizr News",
-        "Close Error": "Close Error",
-        "An Error Occured": "An Error Occurred",
-        "Debug Area": "Debug Area",
-        "Tab Local URL": "Tab Local URL",
-        "PRELOAD": "PRELOAD",
-        "Use Ombi Alias Names": "Use Ombi Alias Names",
-        "TV Show Default Request": "TV Show Default Request",
-        "Add to Combined Downloader": "Add to Combined Downloader",
+        "DB Folder": "DB 폴더",
+        "API Folder": "API 폴더",
+        "Root Folder": "루트 폴더",
+        "Organizr Paths": "Organizr 경로",
+        "Organizr News": "Organizr 뉴스",
+        "Close Error": "닫기 오류",
+        "An Error Occured": "오류 발생됨",
+        "Debug Area": "디버그 영역",
+        "Tab Local URL": "탭 로컬 URL",
+        "PRELOAD": "사전 로드",
+        "Use Ombi Alias Names": "Ombi 별칭 이름 사용",
+        "TV Show Default Request": "TV 쇼 기본 요청",
+        "Add to Combined Downloader": "통합 다운로더에 추가",
         "http(s)://hostname:port/xmlrpc": "http(s)://hostname:port/xmlrpc",
-        "rTorrent API URL Override": "rTorrent API URL Override",
-        "Enable Notify Sounds": "Enable Notify Sounds",
-        "Remember Me Length": "Remember Me Length",
-        "Minimum Authentication for Debug Area": "Minimum Authentication for Debug Area",
-        "Account DN": "Account DN",
-        "Bind Username": "Bind Username",
-        "Account suffix - start with comma - ,ou=people,dc=domain,dc=tld": "Account suffix - start with comma - ,ou=people,dc=domain,dc=tld",
-        "Account Suffix": "Account Suffix",
-        "Account prefix - i.e. Controller\\ from Controller\\Username for AD - uid= for OpenLDAP": "Account prefix - i.e. Controller\\ from Controller\\Username for AD - uid= for OpenLDAP",
-        "Account Prefix": "Account Prefix",
-        "LDAP Backend Type": "LDAP Backend Type",
-        "Strict Plex Friends": "Strict Plex Friends",
+        "rTorrent API URL Override": "rTorrent API URL 재정의",
+        "Enable Notify Sounds": "알림 소리 활성화",
+        "Remember Me Length": "'기억하기' 길이",
+        "Minimum Authentication for Debug Area": "디버그 영역에 대한 최소 인증",
+        "Account DN": "계정 DN",
+        "Bind Username": "사용자 이름 바이딩",
+        "Account suffix - start with comma - ,ou=people,dc=domain,dc=tld": "계정 접미사 - 쉼표로 시작함 - ,ou=people,dc=domain,dc=tld",
+        "Account Suffix": "계정 접미사",
+        "Account prefix - i.e. Controller\\ from Controller\\Username for AD - uid= for OpenLDAP": "계정 접두사 - 예: Controller\\Username의 Controller\\(AD용) - uid= (OpenLDAP용)",
+        "Account Prefix": "계정 접두사",
+        "LDAP Backend Type": "LDAP 백엔드 유형",
+        "Strict Plex Friends": "엄격한 Plex 친구",
         "Unifi": "Unifi",
         "Pi-hole": "Pi-hole",
-        "Check For Updates": "Check For Updates",
-        "I will try and import new strings every Friday": "I will try and import new strings every Friday",
-        "Custom definitions": "Custom definitions",
-        "Show on small screens": "Show on small screens",
-        "Show on medium screens": "Show on medium screens",
-        "Show on large screens": "Show on large screens",
-        "Size": "Size",
-        "Colour": "Colour",
-        "Chart": "Chart",
-        "Data": "Data",
-        "Info": "Info",
+        "Check For Updates": "업데이트 확인",
+        "I will try and import new strings every Friday": "매주 금요일마다 새로운 문자열을 가져오도록 노력하겠습니다",
+        "Custom definitions": "맞춤 정의",
+        "Show on small screens": "작은 화면에 표시",
+        "Show on medium screens": "중간 화면에 표시",
+        "Show on large screens": "큰 화면에 표시",
+        "Size": "크기",
+        "Colour": "색상",
+        "Chart": "차트",
+        "Data": "데이터",
+        "Info": "정보",
         "Netdata": "Netdata",
-        "Toggle Title": "Toggle Title",
+        "Toggle Title": "제목 전환",
         "Speedtest": "Speedtest",
-        "Unit of Measurement": "Unit of Measurement",
-        "Enable Pollen": "Enable Pollen",
-        "Enable Air Quality": "Enable Air Quality",
-        "Enable Weather": "Enable Weather",
-        "Need Help With Coordinates?": "Need Help With Coordinates?",
-        "Longitude": "Longitude",
-        "Latitude": "Latitude",
+        "Unit of Measurement": "측정 단위",
+        "Enable Pollen": "Pollen 활성화",
+        "Enable Air Quality": "대기질 활성화",
+        "Enable Weather": "날씨 활성화",
+        "Need Help With Coordinates?": "좌표와 관련하여 도움이 필요하십니까?",
+        "Longitude": "경도",
+        "Latitude": "위도",
         "Weather-Air": "Weather-Air",
-        "Compact view": "Compact view",
+        "Compact view": "간략히 보기",
         "http://domain.com/monitorr/": "http://domain.com/monitorr/",
         "Monitorr": "Monitorr",
-        "Top Platforms": "Top Platforms",
-        "Top Users": "Top Users",
+        "Top Platforms": "상위 플랫폼",
+        "Top Users": "상위 사용자",
         "http://<ip>:<port>": "http://<ip>:<port>",
         "Tautulli": "Tautulli",
-        "Combine stat cards": "Combine stat cards",
+        "Combine stat cards": "통계 카드 결합",
         "http(s)://hostname:port/admin/": "http(s)://hostname:port/admin/",
-        "Youtube API Key": "Youtube API Key",
-        "Misc": "Misc",
-        "Multiple tags using CSV - tag1,tag2": "Multiple tags using CSV - tag1,tag2",
-        "Tags": "Tags",
+        "Youtube API Key": "Youtube API ",
+        "Misc": "기타",
+        "Multiple tags using CSV - tag1,tag2": "CSV를 사용하는 여러 태그 - tag1,tag2",
+        "Tags": "태그",
         "HealthChecks API URL": "HealthChecks API URL",
         "HealthChecks": "HealthChecks",
-        "Get Unifi Site": "Get Unifi Site",
-        "Grab Unifi Site": "Grab Unifi Site",
-        "Site Name": "Site Name",
+        "Get Unifi Site": "Unifi 사이트 얻기",
+        "Grab Unifi Site": "Unifi 사이트 잡기",
+        "Site Name": "사이트 이름",
         "Unifi API URL": "Unifi API URL",
-        "Show Denied": "Show Denied",
-        "Show Unapproved": "Show Unapproved",
-        "Show Approved": "Show Approved",
-        "Show Unavailable": "Show Unavailable",
-        "Show Available": "Show Available",
-        "Disable Certificate Check": "Disable Certificate Check",
+        "Show Denied": "거부된 항목 표시",
+        "Show Unapproved": "승인되지 않은 항목 표시",
+        "Show Approved": "승인된 항목 표시",
+        "Show Unavailable": "사용할 수 없는 항목 표시",
+        "Show Available": "사용할 수 있는 항목 표시",
+        "Disable Certificate Check": "인증서 확인 비활성화",
         "Organizr appends the url with": "Organizr appends the url with",
         "unless the URL ends in": "unless the URL ends in",
-        "ATTENTION": "ATTENTION",
-        "API Version": "API Version",
+        "ATTENTION": "중요사항",
+        "API Version": "API 버전",
         "JDownloader": "JDownloader",
-        "http(s)://hostname:port - make sure if Jelly fin to end url with /jellyfin": "http(s)://hostname:port - make sure if Jelly fin to end url with /jellyfin",
+        "http(s)://hostname:port - make sure if Jelly fin to end url with /jellyfin": "http(s)://hostname:port - Jellyfin이 URL을 /jellyfin으로 끝내는지 확인하세요",
         "Emby-Jellyfin": "Emby-Jellyfin",
-        "Tab Auto Action Minutes": "Tab Auto Action Minutes",
-        "Tab Auto Action": "Tab Auto Action",
-        "To revert back to default, save with no value defined in the relevant field.": "To revert back to default, save with no value defined in the relevant field.",
-        "e.g. UA-XXXXXXXXX-X": "e.g. UA-XXXXXXXXX-X",
-        "Google Analytics Tracking ID": "Google Analytics Tracking ID",
-        "Show Debug Errors": "Show Debug Errors",
-        "Use Logo instead of Title on Login Page": "Use Logo instead of Title on Login Page",
-        "Login Logo": "Login Logo",
-        "Meta Description": "Meta Description",
-        "HealthChecks Settings": "HealthChecks Settings",
+        "Tab Auto Action Minutes": "탭 자동 동작 시간(분)",
+        "Tab Auto Action": "탭 자동 동작",
+        "To revert back to default, save with no value defined in the relevant field.": "기본값으로 되돌리려면 해당 필드에 정의된 값을 입력하지 않고 저장하세요.",
+        "e.g. UA-XXXXXXXXX-X": "예: UA-XXXXXXXXX-X",
+        "Google Analytics Tracking ID": "Google Analytics 추적 ID",
+        "Show Debug Errors": "디버그 오류 표시",
+        "Use Logo instead of Title on Login Page": "로그인 페이지에 제목 대신 로고 사용",
+        "Login Logo": "로그인 로고",
+        "Meta Description": "메타 설명",
+        "HealthChecks Settings": "HealthChecks 설정",
         "AdamSmith": "AdamSmith",
-        "Emby User to be used as template for new users": "Emby User to be used as template for new users",
+        "Emby User to be used as template for new users": "신규 사용자를 위한 템플릿으로 사용할 Emby 사용자",
         "localhost:8086": "localhost:8086",
-        "Emby server adress": "Emby server adress",
-        "enter key from emby": "enter key from emby",
-        "Emby API key": "Emby API key",
-        "Music Labels (comma separated)": "Music Labels (comma separated)",
-        "Movies Labels (comma separated)": "Movies Labels (comma separated)",
-        "TV Labels (comma separated)": "TV Labels (comma separated)",
-        "Template #1": "Template #1",
-        "Enable Debug Output on Email Test": "Enable Debug Output on Email Test",
+        "Emby server adress": "Emby 서버 주소",
+        "enter key from emby": "emby에서 키를 입력하세요",
+        "Emby API key": "Emby API ",
+        "Music Labels (comma separated)": "음악 레이블 (쉼표로 구분됨)",
+        "Movies Labels (comma separated)": "영화 레이블 (쉼표로 구분됨)",
+        "TV Labels (comma separated)": "TV 레이블 (쉼표로 구분됨)",
+        "Template #1": "템플릿 #1",
+        "Enable Debug Output on Email Test": "이메일 테스트에서 디버그 출력 활성화",
         "i.e. 10.0.0.0/24 or 10.0.0.20": "i.e. 10.0.0.0/24 or 10.0.0.20",
-        "Auth Proxy Whitelist": "Auth Proxy Whitelist",
-        "i.e. X-Forwarded-User": "i.e. X-Forwarded-User",
-        "Auth Proxy Header Name": "Auth Proxy Header Name",
-        "Auth Proxy": "Auth Proxy",
-        "Enable Local Address Forward": "Enable Local Address Forward",
+        "Auth Proxy Whitelist": "인증 프록시 화이트리스트",
+        "i.e. X-Forwarded-User": "예시: X-Forwarded-User",
+        "Auth Proxy Header Name": "인증 프록시 헤더 이름",
+        "Auth Proxy": "인증 프록시",
+        "Enable Local Address Forward": "로컬 주소 전달 활성화",
         "http://home.local": "http://home.local",
-        "Local Address": "Local Address",
-        "only domain and tld - i.e. domain.com": "only domain and tld - i.e. domain.com",
-        "WAN Domain": "WAN Domain",
+        "Local Address": "로컬 주소",
+        "only domain and tld - i.e. domain.com": "도메인과 TLD만 - 예: domain.com",
+        "WAN Domain": "WAN 도메인",
         "i.e. 123.123.123.123": "예: 123.123.123.123",
-        "Override Local IP To": "Override Local IP To",
-        "Override Local IP From": "Override Local IP From",
-        "Disable Image Dropdown": "Disable Image Dropdown",
-        "Disable Icon Dropdown": "Disable Icon Dropdown",
-        "Enable Traefik Auth Redirect": "Enable Traefik Auth Redirect",
+        "Override Local IP To": "로컬 IP를 다음으로 재정의",
+        "Override Local IP From": "로컬 IP를 다음에서 재정의",
+        "Disable Image Dropdown": "이미지 드롭다운 비활성화",
+        "Disable Icon Dropdown": "아이템 드롭다운 비활성화",
+        "Enable Traefik Auth Redirect": "Traefik 인증 리다이렉트 활성화",
         "iFrame Sandbox": "iFrame 샌드박스",
         "Login Lockout Seconds": "로그인 잠금 시간(초)",
         "Max Login Attempts": "최대 로그인 시도 횟수",
         "Test Login": "로그인 테스트",
-        "Ignore External 2FA on Local Subnet": "Ignore External 2FA on Local Subnet",
+        "Ignore External 2FA on Local Subnet": "로컬 서브넷에서 외부 2FA 무시",
         "Large modal": "대형 모달",
         "An Error Occurred": "오류가 발생됨",
-        "Type your message": "귀하의 메시지 입력하기",
+        "Type your message": "귀하의 메시지를 입력하세요",
         "Bookmark Tabs": "북마크 탭",
         "Bookmark Categories": "북마크 카테고리",
         "Open Collective": "Open Collective",
-        "Github Sponsor": "Github 후원자",
+        "Github Sponsor": "Github 스폰서",
         "Backers": "후원자",
         "Tab Folder": "탭 폴더",
         "Cache Folder": "캐시 폴더",
@@ -629,34 +631,34 @@
         "Import Jellyfin Users": "Jellyfin 사용자 가져오기",
         "More": "더 많게",
         "Less": "더 적게",
-        "OpenCollective Sponsor": "OpenCollective Sponsor",
-        "Patreon Sponsor": "Patreon Sponsor",
-        "New Organizr API v2": "New Organizr API v2",
-        "Develop Branch Users - Please switch to Master for mean time": "Develop Branch Users - Please switch to Master for mean time",
-        "API V2 TESTING almost complete": "API V2 TESTING almost complete",
-        "Important Messages - Each message can now be ignored using ignore button": "Important Messages - Each message can now be ignored using ignore button",
-        "Minimum PHP Version change": "Minimum PHP Version change",
+        "OpenCollective Sponsor": "OpenCollective 스폰서",
+        "Patreon Sponsor": "Patreon 스폰서",
+        "New Organizr API v2": "새로운 Organizr API v2",
+        "Develop Branch Users - Please switch to Master for mean time": "Develop 브랜치 사용자 - 당분간 Master로 전환해주세요",
+        "API V2 TESTING almost complete": "API V2 테스트가 거의 완료되었습니다",
+        "Important Messages - Each message can now be ignored using ignore button": "중요 메시지 - 이제 무시 버튼을 사용하여 각 메시지를 무시할 수 있습니다",
+        "Minimum PHP Version change": "최소 PHP 버전 변경",
         "You": "당신",
-        "Drop Certificate file here to upload": "Drop Certificate file here to upload",
+        "Drop Certificate file here to upload": "업로드하려면 여기에 인증서 파일을 드롭하세요",
         "Custom Certificate Loaded": "사용자 지정 인증서 로드됨",
-        "By default, Organizr uses certificates from https://curl.se/docs/caextract.html": "By default, Organizr uses certificates from https://curl.se/docs/caextract.html",
-        "If you would like to use your own certificate, please upload it below.  You will then need to enable each homepage item to use it.": "If you would like to use your own certificate, please upload it below.  You will then need to enable each homepage item to use it.",
-        "i.e. X-Forwarded-Email": "i.e. X-Forwarded-Email",
-        "Auth Proxy Header Name for Email": "Auth Proxy Header Name for Email",
-        "Custom Recover Password Text": "Custom Recover Password Text",
-        "Disable Recover Password": "Disable Recover Password",
-        "Blacklisted Error Message": "Blacklisted Error Message",
-        "Blacklisted IP's": "Blacklisted IP's",
+        "By default, Organizr uses certificates from https://curl.se/docs/caextract.html": "기본적으로 Organizr는 https://curl.se/docs/caextract.html의 인증서를 사용합니다",
+        "If you would like to use your own certificate, please upload it below.  You will then need to enable each homepage item to use it.": "자체 인증서를 사용하고 싶다면 아래에 업로드해주세요. 그런 다음 이를 사용하려면 각 홈페이지 항목을 활성화해야 합니다.",
+        "i.e. X-Forwarded-Email": "예: X-Forwarded-Email",
+        "Auth Proxy Header Name for Email": "이메일의 인증 프록시 헤더 이름",
+        "Custom Recover Password Text": "사용자 지정 복구 암호 텍스트",
+        "Disable Recover Password": "복구 암호 비활성화",
+        "Blacklisted Error Message": "'블랙리스트에 등재됨' 오류 메시지",
+        "Blacklisted IP's": "블랙리스트에 등재된 IP",
         "http(s)://domain": "http(s)://domain",
-        "Traefik Domain for Return Override": "Traefik Domain for Return Override",
-        "Jellyfin Token": "Jellyfin Token",
+        "Traefik Domain for Return Override": "반환 재정의를 위한 Traefik 도메인",
+        "Jellyfin Token": "Jellyfin 토큰",
         "Jellyfin URL": "Jellyfin URL",
-        "Enable LDAP TLS": "Enable LDAP TLS",
-        "Enable LDAP SSL": "Enable LDAP SSL",
-        "Bind Password": "Bind Password",
+        "Enable LDAP TLS": "LDAP TLS 활성화",
+        "Enable LDAP SSL": "LDAP SSL 활성화",
+        "Bind Password": "암호 바인딩",
         "http(s) | ftp(s) | ldap(s)://hostname:port": "http(s) | ftp(s) | ldap(s)://hostname:port",
         "Plex Admin Username": "Plex 관리자 사용자 이름",
-        "Default Settings Tab": "Default Settings Tab",
+        "Default Settings Tab": "기본 설정 탭",
         "Certificate": "인증서",
         "Ping": "핑",
         "API": "API",
@@ -665,61 +667,268 @@
         "http(s)://domain.com": "http(s)://domain.com",
         "Jellyfin SSO URL": "Jellyfin SSO URL",
         "Jellyfin API URL": "Jellyfin API URL",
-        "Ombi Fallback Password": "Ombi Fallback Password",
-        "Ombi Fallback User": "Ombi Fallback User",
-        "Petio Fallback Password": "Petio Fallback Password",
-        "Petio Fallback User": "Petio Fallback User",
+        "Ombi Fallback Password": "Ombi 대체 암호",
+        "Ombi Fallback User": "Ombi 대체 사용자",
+        "Petio Fallback Password": "Petio 대체 암호",
+        "Petio Fallback User": "Petio 대체 사용자",
         "Petio URL": "Petio URL",
-        "Overseerr Fallback Password": "Overseerr Fallback Password",
-        "Overseerr Fallback User": "Overseerr Fallback User",
+        "Overseerr Fallback Password": "Overseerr 대체 암호",
+        "Overseerr Fallback User": "Overseerr 대체 사용자",
         "Overseerr URL": "Overseerr URL",
-        "Multiple URL's": "Multiple URL's",
-        "Using multiple SSO application will cause your Cookie Header item to increase.  If you haven't increased it by now, please follow this guide": "Using multiple SSO application will cause your Cookie Header item to increase.  If you haven't increased it by now, please follow this guide",
-        "Please Read First": "Please Read First",
+        "Multiple URL's": "다중 URL",
+        "Using multiple SSO application will cause your Cookie Header item to increase.  If you haven't increased it by now, please follow this guide": "여러 SSO 애플리케이션을 사용하면 쿠키 헤더 항목이 증가합니다. 지금까지 늘리지 않았다면 이 가이드를 따르세요.",
+        "Please Read First": "반드시 먼저 읽어주세요",
         "Jellyfin": "Jellyfin",
         "Petio": "Petio",
         "Overseerr": "Overseerr",
         "FYI": "FYI",
         "https://app.plex.tv/auth#?resetPassword": "https://app.plex.tv/auth#?resetPassword",
-        "Change Password on Plex Website": "Change Password on Plex Website",
+        "Change Password on Plex Website": "Plex 웹사이트에서 암호 변경",
         "Action": "동작",
         "IP": "IP",
-        "Browser": "Browser",
-        "Expires": "Expires",
-        "Created": "Created",
-        "Version": "Version",
-        "Files": "Files",
-        "Backup Organizr": "Backup Organizr",
-        "Create Backup": "Create Backup",
-        "Select or type Image": "Select or type Image",
-        "Choose": "Choose",
-        "Choose Blackberry Theme Icon": "Choose Blackberry Theme Icon",
-        "Save Tab Order": "Save Tab Order",
-        "Drag Homepage Items to Order Them": "Drag Homepage Items to Order Them",
-        "Preview": "Preview",
-        "Text Color": "Text Color",
-        "Background Color": "Background Color",
-        "Bookmark Tab Editor": "Bookmark Tab Editor",
-        "Add New Bookmark Category": "Add New Bookmark Category",
-        "Bookmark Category Editor": "Bookmark Category Editor",
-        "Auto-Expand Nav Bar": "Auto-Expand Nav Bar",
-        "Auto-Collapse Categories": "Auto-Collapse Categories",
-        "Expand All Categories": "Expand All Categories",
-        "Show Organizr Sign out & in Button on Sidebar": "Show Organizr Sign out & in Button on Sidebar",
-        "Show Organizr Docs Link": "Show Organizr Docs Link",
-        "Show Organizr Support Link": "Show Organizr Support Link",
-        "Show Organizr Feature Request Link": "Show Organizr Feature Request Link",
-        "Show GitHub Repo Link": "Show GitHub Repo Link",
-        "Theme CSS": "Theme CSS",
-        "Custom CSS": "Custom CSS",
-        "FavIcon": "FavIcon",
-        "Notifications": "Notifications",
-        "Colors & Themes": "Colors & Themes",
-        "Options": "Options",
-        "Login Page": "Login Page",
-        "Top Bar": "Top Bar",
-        "Bookmark Settings": "Bookmark Settings",
-        "HnL Settings": "HnL Settings",
-        "Not Installed": "Not Installed"
+        "Browser": "브라우저",
+        "Expires": "만료",
+        "Created": "생성됨",
+        "Version": "버전",
+        "Files": "파일",
+        "Backup Organizr": "Organizr 백업",
+        "Create Backup": "백업 생성",
+        "Select or type Image": "이미지 선택/입력",
+        "Choose": "선택",
+        "Choose Blackberry Theme Icon": "Blackberry 테마 아이콘 선택",
+        "Save Tab Order": "탭 순서 저장",
+        "Drag Homepage Items to Order Them": "홈페이지 항목을 끌어서 순서 지정하기",
+        "Preview": "미리보기",
+        "Text Color": "텍스트 색상",
+        "Background Color": "배경 색상",
+        "Bookmark Tab Editor": "북마크 탭 편집기",
+        "Add New Bookmark Category": "새 북마크 카테고리 추가",
+        "Bookmark Category Editor": "북마크 카테고리 편집기",
+        "Auto-Expand Nav Bar": "내비 바 자동 확장",
+        "Auto-Collapse Categories": "카테고리 자동 축소",
+        "Expand All Categories": "모든 카테고리 확장",
+        "Show Organizr Sign out & in Button on Sidebar": "사이드바에 Organizr 로그아웃 및 로그인 버튼 표시",
+        "Show Organizr Docs Link": "Organizr 문서 링크 표시",
+        "Show Organizr Support Link": "Organizr 지원 링크 표시",
+        "Show Organizr Feature Request Link": "Organizr 기능 요청 링크 표시",
+        "Show GitHub Repo Link": "GitHub 저장소 링크 표시",
+        "Theme CSS": "테마 CSS",
+        "Custom CSS": "사용자 지정 CSS",
+        "FavIcon": "파비콘",
+        "Notifications": "알림",
+        "Colors & Themes": "색상 및 테마",
+        "Options": "옵션",
+        "Login Page": "로그인 페이지",
+        "Top Bar": "상위 바",
+        "Bookmark Settings": "북마크 설정",
+        "HnL Settings": "HnL 설정",
+        "Not Installed": "설치되지 않음",
+        "Money not an option?  No problem.  Show some love to this Google Ad below:": "돈은 선택사항이 아닌가요? 문제없습니다. 아래 구글 광고에 많은 사랑을 보내주세요:",
+        "Please click the button to continue.": "계속하려면 아래의 버튼을 클릭하세요.",
+        "Need specialized support or just want to support Organizr?  If so head to Open Collective...": "전문적인 지원이 필요하거나 단지 Organizr를 지원하고 싶으신가요? 그렇다면 Open Collective로 가세요...",
+        "Need specialized support or just want to support Organizr?  If so head to Patreon...": "전문적인 지원이 필요하거나 단지 Organizr를 지원하고 싶으신가요? 그렇다면 Patreon으로 가세요...",
+        "Want to donate a small amount of Crypto?.": "소량의 암호화폐를 기부하고 싶으신가요?.",
+        "Please use the QR Code or Wallet ID.": "QR 코드나 지갑 ID를 이용해 주세요.",
+        "If you use the Square Cash App, you can donate with that if you like.": "Square Cash 앱을 사용하는 경우 원하는 경우 이를 통해 기부할 수 있습니다.",
+        "I have chosen to go with PayPal Pools so everyone can see how much people have donated.": "저는 모든 사람들이 얼마나 많은 기부를 했는지 볼 수 있도록 페이팔 풀스를 선택했습니다.",
+        "Want to show support on Github?  Sponsor me :)": "Github에서 지원을 보여주고 싶으십니까? 후원해주세요 :)",
+        "If messages get stuck sending, please turn this option off.": "메시지 전송이 중단되면 이 옵션을 꺼주세요.",
+        "Save and reload!": "저장 후 다시 로드하세요!",
+        "Copy and paste the 4 values into Organizr": "4개의 값을 복사하여 Organizr에 붙여넣습니다",
+        "Click the overview tab on top left": "왼쪽 상단의 개요(overview) 탭을 클릭하세요",
+        "Frontend (JQuery) - Backend (PHP)": "프론트엔드 (JQuery) - 백엔드 (PHP)",
+        "Create an App called whatever you like and choose a cluster (Close to you)": "원하는 이름으로 앱을 만들고 클러스터를 선택하세요 (가까운 위치)",
+        "Signup for Pusher [FREE]": "Pusher에 가입합니다 [무료]",
+        "Connection": "연결",
+        "Enabled": "활성화됨",
+        "Internal URL": "내부 URL",
+        "External URL": "외부 URL",
+        "UUID": "UUID",
+        "Service Name": "서비스 이름",
+        "Make sure to save before using the import button on Services tab": "서비스 탭의 가져오기 버튼을 사용하기 전에 반드시 저장하세요",
+        "Do not use a Read-Only Token as that will not give a correct UUID for sending the results to HealthChecks.io": "결과를 HealthChecks.io로 보내기 위한 올바른 UUID를 제공하지 않으므로 읽기 전용 토큰을 사용하지 마십시오",
+        "Please use a Full Access Token": "전체 액세스(Full Access) 토큰을 사용하세요.",
+        "URL for HealthChecks API": "HealthChecks API의 URL",
+        "403 Error as Success": "403 오류를 성공으로 표시",
+        "401 Error as Success": "401 오류를 성공으로 표시",
+        "HealthChecks Ping URL": "HealthChecks 핑 URL",
+        "URL for HealthChecks Ping": "HealthChecks 핑의 URL",
+        "As often as you like - i.e. every 1 minute": "원하는 만큼 주기적으로 갱신 - 예: 1분 마다",
+        "Frequency": "주파수",
+        "CRON Job URL": "CRON 작업 URL",
+        "Once this plugin is setup, you will need to setup a CRON job": "이 플러그인이 한 번 설정되면 CRON 작업을 설정해야 합니다",
+        "Services": "서비스",
+        "Import Services": "서비스 가져오기",
+        "Add New Service": "새 서비스 추가",
+        "After enabling for the first time, please reload the page - Menu is located under User menu on top right": "처음 활성화한 후 페이지를 새로고침하세요 - 메뉴는 오른쪽 상단의 사용자 메뉴 아래에 있습니다",
+        "Emby Settings": "Emby 설정",
+        "Plex Settings": "Plex 설정",
+        "Backend": "백엔드",
+        "Templates": "템플릿",
+        "Test & Options": "테스트 및 옵션",
+        "Sender Information": "보낸 사람 정보",
+        "Host": "호스트",
+        "Open your custom Bookmark page via menu.": "메뉴를 통해 사용자 정의 북마크 페이지를 여세요.",
+        "Create Bookmark tabs in the new area in": "다음을 통해 새 영역에 북마크 탭을 생성하세요:",
+        "Create Bookmark categories in the new area in": "다음을 통해 새 영역에 북마크 카테고리를 생성하세요:",
+        "Add tab that points to": "다음을 가리키는 탭을 추가하세요:",
+        "and set it's type to": "그리고 유형을 다음으로 설정하세요:",
+        "Checking for bookmark default category...": "북마크 기본 카테고리를 확인하는 중...",
+        "Checking for Bookmark tab...": "북마크 탭을 확인하는 중...",
+        "Automatic Setup Tasks": "자동 설정 작업",
+        "Located at": "다음에 위치함:",
+        "Custom Certificate Status": "사용자 지정 인증서 상태",
+        "Will play a sound if the server goes down and will play sound if comes back up.": "서버가 다운되면 소리가 나고, 서버가 다시 시작되면 소리가 납니다.",
+        "Please choose a unique value for added security": "보안을 강화하려면 고유한 값을 선택하세요",
+        "IPv4 only at the moment - This must be set to work, will accept subnet or IP address": "현재는 IPv4만 가능 - 작동하도록 설정해야 하며, 서브넷 혹은 IP 주소를 허용합니다",
+        "Enable option to set Auth Proxy Header Login": "인증 프록시 헤더 로그인을 설정하는 옵션을 활성화합니다",
+        "Text or HTML for recovery password section": "복구 암호 섹션에 대한 텍스트 혹은 HTML",
+        "Disables recover password area": "암호 복구 영역을 비활성화합니다",
+        "Enables the local address forward if on local address and accessed from WAN Domain": "로컬 주소에 있고 WAN 도메인에서 액세스하는 경우 로컬 주소 전달을 활성화합니다",
+        "Full local address of organizr install - i.e. http://home.local or http://192.168.0.100": "Organizr 설치의 전체 로컬 주소 - 예: http://home.local 혹은 http://192.168.0.100",
+        "Enter domain if you wish to be forwarded to a local address - Local Address filled out on next item": "로컬 주소로 전달할 경우 도메인 입력 - 다음 항목에 로컬 주소 입력하기",
+        "IPv4 only at the moment - This will set your login as local if your IP falls within the From and To": "현재 IPv4만 가능 - IP가 수신인 및 수신인에 속할 경우 로그인이 로컬로 설정됩니다",
+        "Default status of Remember Me button on login screen": "로그인 화면의 기억하기 버튼 기본 상태",
+        "Number of days cookies and tokens will be valid for": "다음에 대한 쿠키 및 토큰이 유효한 날 수:",
+        "Enable this to hide the Registration button on the login screen": "로그인 화면에서 등록 버튼을 숨기려면 이 옵션을 활성화하세요",
+        "Sets the password for the Registration form on the login screen": "로그인 화면에서 등록 양식의 암호를 설정하세요",
+        "WARNING! This will block anyone with these IP's": "경고! 이 IP를 가진 사람은 누구나 차단됩니다",
+        "WARNING! This can potentially mess up your iFrames": "경고! 이는 잠재적으로 iFrame을 망칠 수 있습니다",
+        "Please use a FQDN on this URL Override": "이 URL 재정의에는 FQDN을 사용하세요",
+        "This will enable the webserver to forward errors so traefik will accept them": "이렇게 하면 웹서버가 오류를 전달할 수 있으므로 traefik이 오류를 수락합니다",
+        "Please make sure to use local IP address and port - You also may use local dns name too.": "로컬 IP 주소와 포트를 사용해야 합니다. - 로컬 DNS 이름도 사용할 수 있습니다.",
+        "Remember! Please save before using the test button!": "기억하세요! 테스트 버튼을 사용하기 전 꼭 저장해주세요!",
+        "This will enable the use of TLS for LDAP connections": "LDAP 연결에 TLS를 사용할 수 있게 됩니다",
+        "This will enable the use of SSL for LDAP connections": "LDAP 연결에 SSL을 사용할 수 있게 됩니다",
+        "Enabling this will bypass external 2FA security if user is on local Subnet": "이 기능을 활성화하면 사용자가 로컬 서브넷에 있는 경우 외부 2FA 보안을 우회합니다",
+        "Enabling this will only allow Friends that have shares to the Machine ID entered above to login, Having this disabled will allow all Friends on your Friends list to login": "이 기능을 활성화하면 위에 입력한 컴퓨터 ID에 공유된 친구만 로그인할 수 있으며, 이 기능을 비활성화하면 친구 목록에 있는 모든 친구가 로그인할 수 있습니다",
+        "Since you are using the official Docker image, you can just restart your Docker container to update Organizr": "공식 Docker 이미지를 사용하고 있으므로 Docker 컨테이너를 다시 시작하여 Organizr를 업데이트할 수 있습니다",
+        "Since you are using the Official Docker image, Change the image to change the branch": "공식 Docker 이미지를 사용하고 있으므로 이미지를 변경하여 브랜치를 변경합니다",
+        "Choose which Settings Tab to be default when opening settings page": "설정 페이지를 열 때 기본값으로 설정할 설정 탭을 선택하세요",
+        "Please make sure to use the same (sub)domain to access Jellyfin as Organizr's": "Jellyfin에 접속하려면 Organizr와 동일한 (하위)도메인을 사용해야 합니다",
+        "Please make sure to use the local address to the API": "API에 로컬 주소를 사용했는지 확인하세요",
+        "DO NOT SET THIS TO YOUR ADMIN ACCOUNT. We recommend you create a local account as a \"catch all\" for when Organizr is unable to perform SSO.  Organizr will request a User Token based off of this user credentials": "이 계정을 관리자 계정으로 설정하지 마십시오. Organizr가 SSO를 수행할 수 없는 경우를 대비하여 \"catch all\"로 로컬 계정을 생성하는 것이 좋습니다. Organizr는 이 사용자 자격 증명을 기반으로 사용자 토큰을 요청합니다.",
+        "Purge Log": "로그 제거",
+        "Avatar": "아바타",
+        "Date Registered": "등록 날짜",
+        "Group": "그룹",
+        "Locked": "잠김",
+        "Copy to Clipboard": "클립보드에 복사",
+        "Choose action:": "동작 선택:",
+        "You may enter multiple URL's using the CSV format.  i.e. link#1,link#2,link#3": "CSV 형식을 사용하여 여러 개의 URL을 입력할 수 있습니다.  예: link#1,link#2,link#3",
+        "Used to set the description for SEO meta tags": "SEO 메타 태그에 대한 설명을 설정하는 데 사용됩니다",
+        "Also sets the title of your site": "또한 사이트 제목을 설정합니다",
+        "Up to date": "이미 최신임",
+        "Loading Pihole...": "Pihole 로드 중...",
+        "Loading Unifi...": "Unifi 로드 중...",
+        "Loading Weather...": "Weather 로드 중...",
+        "Loading Tautulli...": "Tautulli 로드 중...",
+        "Loading Health Checks...": "Health Checks 로드 중...",
+        "Loading Speedtest...": "Speedtest 로드 중...",
+        "Loading Uptime Kuma...": "Uptime Kuma 로드 중...",
+        "Health Checks": "Health Checks",
+        "UniFi": "UniFi",
+        "Connection Error to rTorrent": "rTorrent 연결 오류",
+        "Request a Show or Movie": "쇼 혹은 영화 요청",
+        "Marketplace Settings": "스토어 설정",
+        "Theme Settings": "테마 설정",
+        "Plugin Settings": "플러그인 설정",
+        "External Marketplace Repo": "외부 스토어 리포지토리",
+        "Only supports Github repos": "Github 리포지토리만 지원합니다",
+        "Github Person Access Token": "Github 개인 액세스 토큰",
+        "The Github Person Access Token will help with API rate limiting as well as let you access your own Private Repos": "Github 개인 액세스 토큰은 API 속도 제한에 도움이 될 뿐만 아니라 자신의 개인 저장소에 액세스할 수 있게 해줍니다",
+        "Check for Theme Updates": "테마 업데이트 확인",
+        "MIN GROUP": "최소 그룹",
+        "MAX GROUP": "최대 그룹",
+        "ADD TO ADMIN": "ADMIN 계정에 추가",
+        "Other": "기타",
+        "Socks": "Socks",
+        "Enable Debug Log": "디버그 로그 활성화",
+        "Enable the option to have socks output to logs": "Socks를 로그로 출력하는 옵션을 활성화합니다",
+        "Max Debug Data Rows": "최대 디버그 데이터 행 수",
+        "Max amount of rows in debug log": "디버그 로그의 최대 행 수를 지정합니다",
+        "Logo URL": "로고 URL",
+        "Organizr Title": "Organizr 제목",
+        "Side Menu": "사이드 메뉴",
+        "Allow Side Menu to be Collapsable": "사이드 메뉴 접기 허용",
+        "Collapse Side Menu after clicking Tab": "탭을 클릭한 후 사이드 메뉴 접기",
+        "Side Menu Collapsed at Launch": "실행시 사이드 메뉴 접기",
+        "Disable Homepage Saved Modal": "홈페이지 저장 모달 비활성화",
+        "Disable the modal when saving homepage config settings": "홈페이지 구성 설정을 저장할 때 모달을 비활성화합니다",
+        "Show Easter Eggs": "이스터 에그 표시",
+        "API Documentation": "API 문서",
+        "Organizr Docs": "Organizr 문서",
+        "API Key/Token": "API 키/토큰",
+        "Plex Admin Username or Email": "Plex Admin 사용자 이름 혹은 이메일",
+        "Overseerr Fallback Email": "Overseerr 대체 이메일",
+        "Petio Fallback Email": "Petio 대체 이메일",
+        "Ombi Fallback Email": "Ombi 대체 이메일",
+        "Komga Fallback Email": "Komga 대체 이메일",
+        "Komga Fallback Password": "Komga 대체 암호",
+        "Komga Master Password": "Komga 마스터 암호",
+        "Use Random Media Wallpaper From Media Server": "미디어 서버에서 임의의 미디어 배경화면 사용",
+        "Login Wallpaper URL": "로그인 배경화면 URL",
+        "Organizr Enable Cron Instructions": "Organizr Cron 활성화 지침",
+        "Check Cron Status": "Cron 상태 확인",
+        "Auto-Update Organizr": "Organizr 자동 업데이트",
+        "Auto-Backup Organizr": "Organizr 자동 백업",
+        "Cron Schedule": "Cron 스케쥴",
+        "You may use either Cron format or - @hourly, @daily, @monthly": "Cron 형식을 사용하거나 다음 형식을 사용할 수 있습니다 - @hourly, @daily, @monthly",
+        "# Backups Keep": "보관할 백업 수",
+        "Number of backups to keep": "보관할 백업 수를 지정합니다",
+        "Backup Save Path": "백업 저장 경로",
+        "Folder path to save Organizr Backups - Please test before saving": "Organizr 백업을 저장할 폴더 경로 - 저장하기 전에 테스트하세요",
+        "Override Local IP or Subnet": "로컬 IP 혹은 서브넷 재정의",
+        "Log Save Path": "로그 저장 경로",
+        "Folder path to save Organizr Logs - Please test before saving": "Organizr 로그를 저장할 폴더 경로 - 저장하기 전에 테스트하세요",
+        "Include Database Queries": "데이터베이스 쿼리 포함",
+        "Include Database queries in debug logs": "디버그 로그에 데이터베이스 쿼리 포함",
+        "Live Update Refresh": "라이브 업데이트 새로 고침",
+        "Send Logs to Slack": "Slack에 로그 보내기",
+        "Send Logs to Slack as well": "Slack에도 로그 보내기",
+        "Slack Webhook URL": "Slack 웹훅 URL",
+        "If using Discord make sure to end the URL with /slack": "Discord를 사용하는 경우 URL이 /slack로 끝나야 합니다",
+        "Log Level": "로그 수준",
+        "Maximum Log Files": "최대 로그 파일",
+        "Number of log files to preserve": "보존할 로그 파일 수를 지정합니다",
+        "Log Page Size": "로그 페이지 크기",
+        "Slack Log Level": "Slack 로그 수준",
+        "Slack Channel for Webhook": "웹훅용 Slack 채널",
+        "Channel ID for webhook - Not needed for Discord": "웹훅용 채널 ID - Discord에는 필요하지 않습니다",
+        "Test Slack": "Slack 테스트",
+        "Test only sends a warning message so make sure Slack Log Level is Warning when testing": "테스트는 경고 메시지만 보내므로 테스트 시 Slack 로그 수준이 경고인지 확인하세요",
+        "Check For Update": "업데이트 확인",
+        "Check for update on Organizr load": "Organizr가 로드될 때 업데이트 확인",
+        "Match UserAgent": "UserAgent 일치",
+        "Match Browser UserAgent to Token UserAgent - Can be very aggressive on matching": "브라우저 UserAgent를 토큰 UserAgent와 일치 - 일치 시 매우 공격적일 수 있음",
+        "Cookie Header Guide": "쿠키 헤더 가이드",
+        "Custom Certificate not found - please upload below": "사용자 지정 인증서를 찾을 수 없습니다 - 아래의 드롭박스에 업로드하세요",
+        "Libraries to Include": "포함할 라이브러리",
+        "Libraries to Exclude": "제외할 라이브러리",
+        "User Information Without IP": "IP가 없는 사용자 정보",
+        "Only shows username and no IP information": "사용자 이름만 표시되고 IP 정보는 표시되지 않습니다",
+        "Active Streams": "활성 스트림",
+        "Recent Items": "최근 항목",
+        "Media Search": "미디어 검색",
+        "Media Search Server": "미디어 검색 서버",
+        "Playlists": "재생 목록",
+        "Image Cache Quality": "이미지 캐시 품질",
+        "Use Tautulli custom names for users": "사용자에 대해 Tautulli 사용자 지정 이름 사용",
+        "Library Stats": "라이브러리 통계",
+        "Viewing Stats": "뷰잉 통계",
+        "Misc Stats": "기타 통계",
+        "Use Friendly Name": "친숙한 이름 사용",
+        "Use the friendly name set in tautulli for users.": "사용자를 위해 tautulli에 설정된 친숙한 이름을 사용합니다.",
+        "Multiple API Key/Token's": "다중 API 키/토큰",
+        "Use Custom Certificate": "사용자 지정 인증서 사용",
+        "Show monitor latency": "모니터 지연 시간 표시",
+        "Locale": "지역",
+        "Stats": "통계",
+        "Total Queries": "총 쿼리",
+        "Queries Blocked": "차단된 쿼리",
+        "Percent Blocked": "차단된 비율",
+        "Processing Time": "처리 시간",
+        "Domains on Blocklist": "차단 목록에 있는 도메인",
+        "Disable access from user dropdown menu": "사용자 드롭다운 메뉴에서 액세스 비활성화"
     }
 }

文件差異過大導致無法顯示
+ 4 - 0
js/version.json


二進制
plugins/images/homepage/embyLiveTVTracker.png


二進制
plugins/images/homepage/jellystat.png


二進制
plugins/images/homepage/userWatchStats.png


二進制
plugins/images/tabs/amule.png


二進制
plugins/images/tabs/backrest.png


二進制
plugins/images/tabs/copyparty.png


二進制
plugins/images/tabs/flaresolverr.png


二進制
plugins/images/tabs/prompage.png


二進制
plugins/images/tabs/rustdesk.png


二進制
plugins/images/tabs/trilium.png


二進制
plugins/images/tabs/zipline.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>";
+            }

+ 65 - 27
scripts/linux-update.sh

@@ -1,30 +1,68 @@
 #!/usr/bin/env bash
-if [ -z "$1" ]
-  then
-  echo 'No branch setup.. using v2-master'
-  BRANCH="v2-master"
-elif [ "$1" == "v2-develop" ] || [ "$1" == "develop" ] || [ "$1" == "dev" ]
-  then
-  BRANCH="v2-develop"
-elif [ "$1" == "v2-master" ] || [ "$1" == "master" ]
-  then
-  BRANCH="v2-master"
+
+# Organizr Linux Update Script
+# Docker-compatible automated update script
+
+set -euo pipefail
+
+# Configuration
+GITHUB_REPO="${GITHUB_REPO:-metalcated/Organizr}"
+
+# Determine branch
+if [ -z "${1:-}" ]; then
+    echo "No branch specified, using v2-master"
+    BRANCH="v2-master"
+elif [ "$1" == "v2-develop" ] || [ "$1" == "develop" ] || [ "$1" == "dev" ]; then
+    BRANCH="v2-develop"
+elif [ "$1" == "v2-master" ] || [ "$1" == "master" ]; then
+    BRANCH="v2-master"
 else
-  echo "$1 is not a valid branch, exiting"
-  exit 1
+    echo "$1 is not a valid branch, exiting"
+    exit 1
 fi
-SCRIPTPATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
-UPGRADEPATH=$SCRIPTPATH"/upgrade"
-UPGRADEFILE=$SCRIPTPATH"/upgrade/upgrade.zip"
-FOLDER=$UPGRADEPATH"/Organizr-"${BRANCH#v}
-URL=https://github.com/causefx/Organizr/archive/${BRANCH}.zip
-mkdir -p $UPGRADEPATH                                                  && \
-curl -sSL ${URL} > $UPGRADEFILE                                        && \
-unzip $UPGRADEFILE -d $UPGRADEPATH                                     && \
-cd $FOLDER                                                             && \
-cp -r ./ $SCRIPTPATH/../                                               && \
-cd $SCRIPTPATH                                                         && \
-rm $UPGRADEFILE                                                        && \
-rm -rf $FOLDER                                                         && \
-rm -rf $UPGRADEPATH                                                    && \
-exit 0
+
+# Setup paths
+SCRIPTPATH="$(cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P)"
+UPGRADEPATH="$SCRIPTPATH/upgrade"
+UPGRADEFILE="$UPGRADEPATH/upgrade.zip"
+FOLDER="$UPGRADEPATH/Organizr-${BRANCH#v}"
+URL="https://github.com/$GITHUB_REPO/archive/${BRANCH}.zip"
+
+echo "Updating Organizr from $GITHUB_REPO:$BRANCH"
+
+# Cleanup function
+cleanup() {
+    rm -rf "$UPGRADEPATH" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+# Create upgrade directory
+mkdir -p "$UPGRADEPATH"
+
+# Download with error handling
+echo "Downloading update..."
+if ! curl -sSL --fail --connect-timeout 30 "$URL" -o "$UPGRADEFILE"; then
+    echo "Error: Failed to download update from $URL"
+    exit 1
+fi
+
+# Extract with error handling  
+echo "Extracting files..."
+if ! unzip -q "$UPGRADEFILE" -d "$UPGRADEPATH"; then
+    echo "Error: Failed to extract update files"
+    exit 1
+fi
+
+# Verify extraction
+if [ ! -d "$FOLDER" ]; then
+    echo "Error: Expected folder not found: $FOLDER"
+    exit 1
+fi
+
+# Apply update
+echo "Applying update..."
+cd "$FOLDER"
+cp -r ./* "$SCRIPTPATH/../"
+
+# Cleanup is handled by trap
+echo "Update completed successfully"

+ 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>

部分文件因文件數量過多而無法顯示