jellystat.php 69 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302
  1. <?php
  2. /**
  3. * JellyStat Homepage Plugin for Organizr
  4. * Supports both Emby and Jellyfin servers via JellyStat API or embedded interface
  5. */
  6. trait JellyStatHomepageItem
  7. {
  8. public function jellystatSettingsArray($infoOnly = false)
  9. {
  10. $homepageInformation = [
  11. 'name' => 'JellyStat',
  12. 'enabled' => true,
  13. 'image' => 'plugins/images/homepage/jellystat.png',
  14. 'category' => 'Media Server',
  15. 'settingsArray' => __FUNCTION__
  16. ];
  17. if ($infoOnly) {
  18. return $homepageInformation;
  19. }
  20. $homepageSettings = [
  21. 'debug' => true,
  22. 'settings' => [
  23. 'Enable' => [
  24. $this->settingsOption('enable', 'homepageJellyStatEnabled'),
  25. $this->settingsOption('auth', 'homepageJellyStatAuth'),
  26. ],
  27. 'Display Mode' => [
  28. $this->settingsOption('select', 'homepageJellyStatDisplayMode', ['label' => 'Display Mode', 'options' => [
  29. ['name' => 'Native Statistics View', 'value' => 'native'],
  30. ['name' => 'Embedded JellyStat Interface', 'value' => 'iframe']
  31. ]]),
  32. ],
  33. 'Connection' => [
  34. $this->settingsOption('url', 'jellyStatURL', ['label' => 'JellyStat URL', 'help' => 'URL to your JellyStat instance']),
  35. $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.']),
  36. $this->settingsOption('token', 'jellyStatApikey', ['label' => 'JellyStat API Key', 'help' => 'API key for JellyStat (required for native mode)']),
  37. $this->settingsOption('disable-cert-check', 'jellyStatDisableCertCheck'),
  38. $this->settingsOption('use-custom-certificate', 'jellyStatUseCustomCertificate'),
  39. ],
  40. 'Native Mode Options' => [
  41. $this->settingsOption('number', 'homepageJellyStatRefresh', ['label' => 'Auto-refresh Interval (minutes)', 'min' => 1, 'max' => 60]),
  42. $this->settingsOption('number', 'homepageJellyStatDays', ['label' => 'Statistics Period (days)', 'min' => 1, 'max' => 365]),
  43. $this->settingsOption('switch', 'homepageJellyStatShowLibraries', ['label' => 'Show Library Statistics']),
  44. $this->settingsOption('switch', 'homepageJellyStatShowUsers', ['label' => 'Show User Statistics']),
  45. $this->settingsOption('switch', 'homepageJellyStatShowMostWatched', ['label' => 'Show Most Watched Content']),
  46. $this->settingsOption('switch', 'homepageJellyStatShowRecentActivity', ['label' => 'Show Recent Activity']),
  47. $this->settingsOption('number', 'homepageJellyStatMaxItems', ['label' => 'Maximum Items to Display', 'min' => 5, 'max' => 50]),
  48. ],
  49. 'Most Watched Content' => [
  50. $this->settingsOption('switch', 'homepageJellyStatShowMostWatchedMovies', ['label' => 'Show Most Watched Movies with Posters']),
  51. $this->settingsOption('switch', 'homepageJellyStatShowMostWatchedShows', ['label' => 'Show Most Watched TV Shows with Posters']),
  52. $this->settingsOption('switch', 'homepageJellyStatShowMostListenedMusic', ['label' => 'Show Most Listened Music with Cover Art']),
  53. $this->settingsOption('number', 'homepageJellyStatMostWatchedCount', ['label' => 'Number of Most Watched Items to Display', 'min' => 1, 'max' => 50]),
  54. ],
  55. 'Iframe Mode Options' => [
  56. $this->settingsOption('number', 'homepageJellyStatIframeHeight', ['label' => 'Iframe Height (pixels)', 'min' => 300, 'max' => 2000]),
  57. $this->settingsOption('switch', 'homepageJellyStatIframeScrolling', ['label' => 'Allow Scrolling in Iframe']),
  58. ],
  59. 'Test Connection' => [
  60. $this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
  61. $this->settingsOption('test', 'jellystat'),
  62. ]
  63. ]
  64. ];
  65. return array_merge($homepageInformation, $homepageSettings);
  66. }
  67. public function testConnectionJellyStat()
  68. {
  69. if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('test'), true)) {
  70. return false;
  71. }
  72. $url = $this->config['jellyStatURL'] ?? '';
  73. $token = $this->config['jellyStatApikey'] ?? '';
  74. $disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
  75. $customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
  76. if (empty($url)) {
  77. $this->setAPIResponse('error', 'JellyStat URL not configured', 500);
  78. return false;
  79. }
  80. $displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
  81. if ($displayMode === 'iframe') {
  82. // For iframe mode, just test if the URL is reachable (use main URL for frontend)
  83. try {
  84. $options = $this->requestOptions($url, null, $disableCert, $customCert);
  85. $response = Requests::get($this->qualifyURL($url), [], $options);
  86. if ($response->success) {
  87. $this->setAPIResponse('success', 'Successfully connected to JellyStat', 200);
  88. return true;
  89. } else {
  90. $this->setAPIResponse('error', 'Failed to connect to JellyStat URL', 500);
  91. return false;
  92. }
  93. } catch (Exception $e) {
  94. $this->setAPIResponse('error', 'Connection test failed: ' . $e->getMessage(), 500);
  95. return false;
  96. }
  97. } else {
  98. // For native mode, test API connection
  99. if (empty($token)) {
  100. $this->setAPIResponse('error', 'JellyStat API key not configured for native mode', 500);
  101. return false;
  102. }
  103. try {
  104. // Use internal URL for server-side API calls if configured, otherwise use main URL
  105. $internalUrl = $this->config['jellyStatInternalURL'] ?? '';
  106. $apiUrl = !empty($internalUrl) ? $internalUrl : $url;
  107. $options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
  108. // Test JellyStat API - use query parameter authentication
  109. $testUrl = $this->qualifyURL($apiUrl) . '/api/getLibraries?apiKey=' . urlencode($token);
  110. $response = Requests::get($testUrl, [], $options);
  111. if ($response->success) {
  112. $data = json_decode($response->body, true);
  113. if (isset($data) && is_array($data) && !isset($data['error'])) {
  114. $this->setAPIResponse('success', 'Successfully connected to JellyStat API', 200);
  115. return true;
  116. }
  117. // Check if there's an error message in the response
  118. if (isset($data['error'])) {
  119. $this->writeLog('error', 'JellyStat API test error: ' . $data['error']);
  120. $this->setAPIResponse('error', 'JellyStat API error: ' . $data['error'], 500);
  121. return false;
  122. }
  123. // Log the actual response for debugging
  124. $this->writeLog('error', 'JellyStat API test: Valid HTTP response but invalid data format. Response: ' . substr($response->body, 0, 500));
  125. }
  126. // Log first endpoint failure details
  127. $firstError = "HTTP {$response->status_code}: " . substr($response->body, 0, 200);
  128. $this->writeLog('error', "JellyStat API test failed on /api/getLibraries: {$firstError}");
  129. // If libraries test failed, the API key is likely invalid
  130. $this->writeLog('error', 'JellyStat API key appears to be invalid or JellyStat API is not responding');
  131. // Try basic connection test to see if JellyStat is even running
  132. $response = Requests::get($this->qualifyURL($apiUrl), [], $options);
  133. if ($response->success) {
  134. $this->setAPIResponse('error', 'JellyStat is reachable but API key is invalid or API endpoints are not responding correctly.', 500);
  135. } else {
  136. $this->setAPIResponse('error', 'Cannot connect to JellyStat URL. Check URL and network connectivity.', 500);
  137. }
  138. return false;
  139. } catch (Exception $e) {
  140. $this->writeLog('error', 'JellyStat API test exception: ' . $e->getMessage());
  141. $this->setAPIResponse('error', 'Connection test failed: ' . $e->getMessage(), 500);
  142. return false;
  143. }
  144. }
  145. }
  146. public function jellystatHomepagePermissions($key = null)
  147. {
  148. $displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
  149. // For iframe mode, only URL is required; for native mode, both URL and API key are required
  150. $requiredFields = ['jellyStatURL'];
  151. if ($displayMode === 'native') {
  152. $requiredFields[] = 'jellyStatApikey';
  153. }
  154. $permissions = [
  155. 'test' => [
  156. 'enabled' => [
  157. 'homepageJellyStatEnabled',
  158. ],
  159. 'auth' => [
  160. 'homepageJellyStatAuth',
  161. ],
  162. 'not_empty' => $requiredFields
  163. ],
  164. 'main' => [
  165. 'enabled' => [
  166. 'homepageJellyStatEnabled'
  167. ],
  168. 'auth' => [
  169. 'homepageJellyStatAuth'
  170. ],
  171. 'not_empty' => $requiredFields
  172. ]
  173. ];
  174. return $this->homepageCheckKeyPermissions($key, $permissions);
  175. }
  176. public function getJellyStatMetadata($array)
  177. {
  178. if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('main'), true)) {
  179. return false;
  180. }
  181. $key = $array['key'] ?? null;
  182. if (!$key) {
  183. $this->setAPIResponse('error', 'JellyStat metadata key is not defined', 422);
  184. return false;
  185. }
  186. // For now, return basic metadata from the key (which is the item ID)
  187. // In the future this could be enhanced to fetch full metadata from JellyStat API
  188. $metadata = [
  189. 'guid' => (string)$key,
  190. 'summary' => 'This item data is from JellyStat analytics. Click the JellyStat button to view detailed statistics and analytics for this content.',
  191. 'rating' => '0',
  192. 'duration' => '0',
  193. 'originallyAvailableAt' => '',
  194. 'year' => '',
  195. 'tagline' => 'JellyStat Analytics',
  196. 'genres' => [], // Empty array will be handled as string in frontend
  197. 'actors' => [] // Empty array will be handled as string in frontend
  198. ];
  199. // Create a mock item structure similar to Emby's format
  200. $item = [
  201. 'uid' => (string)$key,
  202. 'title' => 'JellyStat Item',
  203. 'type' => 'jellystat',
  204. 'nowPlayingImageURL' => 'plugins/images/homepage/no-np.png', // Safe fallback image
  205. 'address' => $this->qualifyURL($this->config['jellyStatURL'] ?? ''),
  206. 'tabName' => 'jellystat',
  207. 'openTab' => 'true',
  208. 'metadata' => $metadata
  209. ];
  210. $api['content'][] = $item;
  211. $this->setAPIResponse('success', null, 200, $api);
  212. return $api;
  213. }
  214. public function homepageOrderJellyStat()
  215. {
  216. if ($this->homepageItemPermissions($this->jellystatHomepagePermissions('main'))) {
  217. $displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
  218. if ($displayMode === 'iframe') {
  219. return $this->renderJellyStatIframe();
  220. } else {
  221. return $this->renderJellyStatNative();
  222. }
  223. }
  224. }
  225. private function renderJellyStatIframe()
  226. {
  227. $url = $this->config['jellyStatURL'] ?? '';
  228. $height = $this->config['homepageJellyStatIframeHeight'] ?? 800;
  229. $scrolling = ($this->config['homepageJellyStatIframeScrolling'] ?? true) ? 'auto' : 'no';
  230. return '
  231. <div id="' . __FUNCTION__ . '">
  232. <div class="white-box">
  233. <div class="white-box-header">
  234. <i class="fa fa-bar-chart"></i> JellyStat Dashboard
  235. </div>
  236. <div class="white-box-content" style="padding: 0;">
  237. <iframe
  238. src="' . htmlspecialchars($this->qualifyURL($url)) . '"
  239. width="100%"
  240. height="' . intval($height) . 'px"
  241. style="border: none; border-radius: 0 0 4px 4px;"
  242. scrolling="' . $scrolling . '"
  243. frameborder="0">
  244. <p>Your browser does not support iframes. Please visit <a href="' . htmlspecialchars($url) . '" target="_blank">JellyStat</a> directly.</p>
  245. </iframe>
  246. </div>
  247. </div>
  248. </div>';
  249. }
  250. private function renderJellyStatNative()
  251. {
  252. $refreshInterval = ($this->config['homepageJellyStatRefresh'] ?? 5) * 60000; // Convert minutes to milliseconds
  253. $days = $this->config['homepageJellyStatDays'] ?? 30;
  254. $maxItems = $this->config['homepageJellyStatMaxItems'] ?? 10;
  255. $showLibraries = ($this->config['homepageJellyStatShowLibraries'] ?? true) ? 'true' : 'false';
  256. $showUsers = ($this->config['homepageJellyStatShowUsers'] ?? true) ? 'true' : 'false';
  257. $showMostWatched = ($this->config['homepageJellyStatShowMostWatched'] ?? true) ? 'true' : 'false';
  258. $showRecentActivity = ($this->config['homepageJellyStatShowRecentActivity'] ?? true) ? 'true' : 'false';
  259. $showMostWatchedMovies = ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? true) ? 'true' : 'false';
  260. $showMostWatchedShows = ($this->config['homepageJellyStatShowMostWatchedShows'] ?? true) ? 'true' : 'false';
  261. $showMostListenedMusic = ($this->config['homepageJellyStatShowMostListenedMusic'] ?? true) ? 'true' : 'false';
  262. $mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
  263. $jellyStatUrl = htmlspecialchars($this->qualifyURL($this->config['jellyStatURL'] ?? ''), ENT_QUOTES, 'UTF-8');
  264. return '
  265. <div id="' . __FUNCTION__ . '" style="background: transparent; border: none; padding: 20px;">
  266. <div style="background: rgba(0,0,0,0.1); border-radius: 10px; padding: 20px; backdrop-filter: blur(10px);">
  267. <div style="margin-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 15px;">
  268. <h3 style="color: white; margin: 0; display: inline-block;"><i class="fa fa-bar-chart"></i> JellyStat Analytics</h3>
  269. <span class="pull-right">
  270. <small id="jellystat-last-update" style="color: rgba(255,255,255,0.7);"></small>
  271. <button class="btn btn-xs btn-primary" onclick="refreshJellyStatData()" title="Refresh Data" style="margin-left: 10px;">
  272. <i class="fa fa-refresh" id="jellystat-refresh-icon"></i>
  273. </button>
  274. </span>
  275. </div>
  276. <div>
  277. <div class="row" id="jellystat-content">
  278. <div class="col-lg-12 text-center">
  279. <i class="fa fa-spinner fa-spin" style="color: white;"></i> <span style="color: white;">Loading JellyStat data...</span>
  280. </div>
  281. </div>
  282. </div>
  283. </div>
  284. </div>
  285. <script>
  286. var jellyStatRefreshTimer;
  287. var jellyStatLastRefresh = 0;
  288. function refreshJellyStatData() {
  289. var refreshIcon = $("#jellystat-refresh-icon");
  290. refreshIcon.addClass("fa-spin");
  291. // Show loading state
  292. $("#jellystat-content").html("<div class=\"col-lg-12 text-center\"><i class=\"fa fa-spinner fa-spin\"></i> Loading JellyStat data...</div>");
  293. // Load JellyStat data
  294. getJellyStatData()
  295. .always(function() {
  296. refreshIcon.removeClass("fa-spin");
  297. jellyStatLastRefresh = Date.now();
  298. updateJellyStatLastRefreshTime();
  299. });
  300. }
  301. function updateJellyStatLastRefreshTime() {
  302. if (jellyStatLastRefresh > 0) {
  303. var ago = Math.floor((Date.now() - jellyStatLastRefresh) / 1000);
  304. var timeText = ago < 60 ? ago + "s ago" : Math.floor(ago / 60) + "m ago";
  305. $("#jellystat-last-update").text("Updated " + timeText);
  306. }
  307. }
  308. // Helper function to get icon for content type
  309. function getTypeIcon(collectionType) {
  310. switch(collectionType) {
  311. case "movies": return "fa-film";
  312. case "tvshows": return "fa-television";
  313. case "music": return "fa-music";
  314. case "mixed": return "fa-folder-open";
  315. default: return "fa-folder";
  316. }
  317. }
  318. // Helper function to format duration from ticks
  319. function formatJellyStatDuration(ticks) {
  320. if (!ticks || ticks === 0) return "0 min";
  321. // Convert ticks to seconds (ticks are in 100-nanosecond intervals)
  322. var seconds = ticks / 10000000;
  323. if (seconds < 60) {
  324. return Math.round(seconds) + " sec";
  325. } else if (seconds < 3600) {
  326. return Math.round(seconds / 60) + " min";
  327. } else if (seconds < 86400) {
  328. var hours = Math.floor(seconds / 3600);
  329. var minutes = Math.floor((seconds % 3600) / 60);
  330. return hours + "h " + minutes + "m";
  331. } else {
  332. var days = Math.floor(seconds / 86400);
  333. var hours = Math.floor((seconds % 86400) / 3600);
  334. return days + "d " + hours + "h";
  335. }
  336. }
  337. // Helper function to generate poster URLs from JellyStat/Jellyfin
  338. function getPosterUrl(posterPath, itemId, serverId) {
  339. console.log("getPosterUrl called with:", {posterPath, itemId, serverId});
  340. // Use external URL for frontend poster display to avoid mixed content issues
  341. var jellyStatUrl = ' . json_encode($jellyStatUrl) . ';
  342. console.log("JellyStat URL from config:", jellyStatUrl);
  343. if (!posterPath && !itemId) {
  344. console.log("No poster path or item ID provided");
  345. return null;
  346. }
  347. // If we have a poster path, process it
  348. if (posterPath) {
  349. console.log("Processing poster path:", posterPath);
  350. // If its already an absolute URL, use it directly
  351. if (posterPath.indexOf("http://") === 0 || posterPath.indexOf("https://") === 0) {
  352. console.log("Poster path is absolute URL:", posterPath);
  353. return posterPath;
  354. }
  355. // If its a relative path starting with /, prepend the JellyStat URL
  356. if (jellyStatUrl && posterPath.indexOf("/") === 0) {
  357. var fullUrl = jellyStatUrl + posterPath;
  358. console.log("Generated full URL from relative path:", fullUrl);
  359. return fullUrl;
  360. }
  361. }
  362. // If we have itemId, try to generate JellyStat image proxy URL
  363. if (itemId && jellyStatUrl) {
  364. // JellyStat uses /proxy/Items/Images/Primary endpoint
  365. // Format: /proxy/Items/Images/Primary?id={itemId}&fillWidth=200&quality=90
  366. var baseUrl = jellyStatUrl.replace(/\/+$/, ""); // Remove trailing slashes
  367. var apiUrl = baseUrl + "/proxy/Items/Images/Primary?id=" + itemId + "&fillWidth=200&quality=90";
  368. console.log("Generated JellyStat proxy image URL:", apiUrl);
  369. return apiUrl;
  370. }
  371. console.log("No valid poster URL could be generated");
  372. return null;
  373. }
  374. function getJellyStatData() {
  375. return organizrAPI2("GET", "api/v2/homepage/jellystat")
  376. .done(function(data) {
  377. console.log("JellyStat API Response:", data);
  378. if (data && data.response && data.response.result === "success" && data.response.data) {
  379. console.log("JellyStat Data:", data.response.data);
  380. renderJellyStatData(data.response.data);
  381. } else {
  382. console.error("JellyStat API Error:", data);
  383. var errorMsg = "Failed to load JellyStat data";
  384. if (data && data.response && data.response.message) {
  385. errorMsg += ": " + data.response.message;
  386. }
  387. $("#jellystat-content").html("<div class=\"col-lg-12 text-center text-danger\">" + errorMsg + "</div>");
  388. }
  389. })
  390. .fail(function(xhr, status, error) {
  391. console.error("JellyStat API Request Failed:", xhr, status, error);
  392. $("#jellystat-content").html("<div class=\"col-lg-12 text-center text-danger\">Error loading JellyStat data: " + error + "</div>");
  393. });
  394. }
  395. function renderJellyStatData(stats) {
  396. console.log("Rendering JellyStat data:", stats);
  397. var html = "";
  398. // Server Overview - Summary Stats
  399. if (stats.library_totals) {
  400. console.log("Library totals found:", stats.library_totals);
  401. html += "<div class=\"col-lg-12\" style=\"margin-bottom: 20px;\">";
  402. html += "<div class=\"row\">";
  403. // Total Libraries
  404. html += "<div class=\"col-sm-3\">";
  405. html += "<div class=\"small-box bg-blue\">";
  406. html += "<div class=\"inner\">";
  407. html += "<h3>" + (stats.library_totals.total_libraries || 0) + "</h3>";
  408. html += "<p>Libraries</p>";
  409. html += "</div>";
  410. html += "<div class=\"icon\"><i class=\"fa fa-folder\"></i></div>";
  411. html += "</div></div>";
  412. // Total Items
  413. html += "<div class=\"col-sm-3\">";
  414. html += "<div class=\"small-box bg-green\">";
  415. html += "<div class=\"inner\">";
  416. html += "<h3>" + (stats.library_totals.total_items || 0).toLocaleString() + "</h3>";
  417. html += "<p>Total Items</p>";
  418. html += "</div>";
  419. html += "<div class=\"icon\"><i class=\"fa fa-film\"></i></div>";
  420. html += "</div></div>";
  421. // Total Episodes (if any)
  422. if (stats.library_totals.total_episodes > 0) {
  423. html += "<div class=\"col-sm-3\">";
  424. html += "<div class=\"small-box bg-yellow\">";
  425. html += "<div class=\"inner\">";
  426. html += "<h3>" + (stats.library_totals.total_episodes || 0).toLocaleString() + "</h3>";
  427. html += "<p>Episodes</p>";
  428. html += "</div>";
  429. html += "<div class=\"icon\"><i class=\"fa fa-television\"></i></div>";
  430. html += "</div></div>";
  431. }
  432. // Total Play Time
  433. html += "<div class=\"col-sm-3\">";
  434. html += "<div class=\"small-box bg-red\">";
  435. html += "<div class=\"inner\">";
  436. html += "<h3 style=\"font-size: 18px;\">" + (stats.library_totals.total_play_time || "0 min") + "</h3>";
  437. html += "<p>Total Watched</p>";
  438. html += "</div>";
  439. html += "<div class=\"icon\"><i class=\"fa fa-clock-o\"></i></div>";
  440. html += "</div></div>";
  441. html += "</div></div>";
  442. }
  443. // Content Type Breakdown
  444. if (stats.library_totals && stats.library_totals.type_breakdown) {
  445. html += "<div class=\"col-lg-6\">";
  446. html += "<h5><i class=\"fa fa-pie-chart text-primary\"></i> Content Breakdown</h5>";
  447. html += "<div class=\"table-responsive\">";
  448. html += "<table class=\"table table-striped table-condensed\">";
  449. html += "<thead><tr><th>Type</th><th>Libraries</th><th>Items</th><th>Watch Time</th></tr></thead>";
  450. html += "<tbody>";
  451. Object.keys(stats.library_totals.type_breakdown).forEach(function(type) {
  452. var breakdown = stats.library_totals.type_breakdown[type];
  453. var playTimeFormatted = breakdown.play_time > 0 ? formatJellyStatDuration(breakdown.play_time) : "0 min";
  454. html += "<tr>";
  455. html += "<td><strong>" + breakdown.label + "</strong></td>";
  456. html += "<td><strong style=\"color: #5bc0de;\">" + breakdown.count + "</strong></td>";
  457. html += "<td><strong style=\"color: #5cb85c;\">" + breakdown.items.toLocaleString() + "</strong></td>";
  458. html += "<td><small>" + playTimeFormatted + "</small></td>";
  459. html += "</tr>";
  460. });
  461. html += "</tbody></table></div></div>";
  462. }
  463. // Detailed Library Statistics
  464. if (' . $showLibraries . ' && stats.libraries && stats.libraries.length > 0) {
  465. html += "<div class=\"col-lg-6\">";
  466. html += "<h5><i class=\"fa fa-folder text-info\"></i> Library Details</h5>";
  467. html += "<div class=\"table-responsive\">";
  468. html += "<table class=\"table table-striped table-condensed\">";
  469. html += "<thead><tr><th>Library</th><th>Type</th><th>Items</th><th>Watch Time</th></tr></thead>";
  470. html += "<tbody>";
  471. stats.libraries.slice(0, ' . $maxItems . ').forEach(function(lib) {
  472. var typeIcon = getTypeIcon(lib.collection_type);
  473. html += "<tr>";
  474. html += "<td><i class=\"fa " + typeIcon + " text-muted\"></i> <strong>" + (lib.name || "Unknown Library") + "</strong></td>";
  475. html += "<td><small>" + (lib.type || "Unknown") + "</small></td>";
  476. html += "<td><strong style=\"color: #337ab7;\">" + (lib.item_count || 0).toLocaleString() + "</strong>";
  477. // Show additional counts for TV libraries
  478. if (lib.episode_count > 0) {
  479. html += "<br><small class=\"text-muted\">Episodes: <strong style=\"color: #337ab7;\">" + lib.episode_count.toLocaleString() + "</strong></small>";
  480. }
  481. if (lib.season_count > 0) {
  482. html += "<br><small class=\"text-muted\">Seasons: <strong style=\"color: #337ab7;\">" + lib.season_count.toLocaleString() + "</strong></small>";
  483. }
  484. html += "</td>";
  485. html += "<td><small>" + (lib.total_play_time || "0 min") + "</small></td>";
  486. html += "</tr>";
  487. });
  488. html += "</tbody></table></div></div>";
  489. }
  490. // User Statistics
  491. if (' . $showUsers . ' && stats.users && stats.users.length > 0) {
  492. html += "<div class=\"col-lg-6\">";
  493. html += "<h5><i class=\"fa fa-users\"></i> Active Users (" + stats.users.length + " total)</h5>";
  494. html += "<div class=\"row\">";
  495. stats.users.slice(0, 8).forEach(function(user) {
  496. var lastActivity = "Never";
  497. if (user.last_activity && user.last_activity !== "0001-01-01T00:00:00.0000000Z") {
  498. var activityDate = new Date(user.last_activity);
  499. lastActivity = activityDate.toLocaleDateString();
  500. }
  501. var playCount = user.play_count || 0;
  502. html += "<div class=\"col-md-6 col-sm-6\" style=\"margin-bottom: 10px;\">";
  503. html += "<div class=\"media\">";
  504. html += "<div class=\"media-left\"><i class=\"fa fa-user fa-2x text-muted\"></i></div>";
  505. html += "<div class=\"media-body\">";
  506. html += "<h6 class=\"media-heading\">" + (user.name || "Unknown User") + " <strong style=\"color: #5bc0de;\">" + playCount + " plays</strong></h6>";
  507. html += "<small class=\"text-muted\">Last Activity: " + lastActivity + "</small>";
  508. html += "</div></div></div>";
  509. });
  510. html += "</div></div>";
  511. }
  512. // Most Watched Content
  513. if (' . $showMostWatched . ' && stats.most_watched && stats.most_watched.length > 0) {
  514. html += "<div class=\"col-lg-12\" style=\"margin-top: 20px;\">";
  515. html += "<h5><i class=\"fa fa-star text-warning\"></i> Most Watched Content</h5>";
  516. html += "<div class=\"table-responsive\">";
  517. html += "<table class=\"table table-striped table-condensed\">";
  518. html += "<thead><tr><th>Title</th><th>Type</th><th>Plays</th><th>Runtime</th><th>Year</th></tr></thead>";
  519. html += "<tbody>";
  520. stats.most_watched.slice(0, ' . $maxItems . ').forEach(function(item) {
  521. html += "<tr>";
  522. html += "<td><strong>" + (item.title || "Unknown Title") + "</strong></td>";
  523. html += "<td>" + (item.type || "Unknown") + "</td>";
  524. html += "<td><strong style=\"color: #337ab7;\">" + (item.play_count || 0) + "</strong></td>";
  525. html += "<td>" + (item.runtime || "Unknown") + "</td>";
  526. html += "<td>" + (item.year && item.year !== "N/A" ? item.year : "") + "</td>";
  527. html += "</tr>";
  528. });
  529. html += "</tbody></table></div></div>";
  530. }
  531. // Recent Activity
  532. if (' . $showRecentActivity . ' && stats.recent_activity && stats.recent_activity.length > 0) {
  533. html += "<div class=\"col-lg-12\" style=\"margin-top: 20px;\">";
  534. html += "<h5><i class=\"fa fa-clock-o text-success\"></i> Recent Activity</h5>";
  535. html += "<div class=\"table-responsive\">";
  536. html += "<table class=\"table table-striped table-condensed\">";
  537. html += "<thead><tr><th>Date</th><th>User</th><th>Title</th><th>Type</th></tr></thead>";
  538. html += "<tbody>";
  539. stats.recent_activity.slice(0, ' . $maxItems . ').forEach(function(activity) {
  540. var date = new Date(activity.date);
  541. var formattedDate = date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute:"2-digit"});
  542. html += "<tr>";
  543. html += "<td><small>" + formattedDate + "</small></td>";
  544. html += "<td>" + (activity.user || "Unknown User") + "</td>";
  545. html += "<td><strong>" + (activity.title || "Unknown Title") + "</strong></td>";
  546. html += "<td>" + (activity.type || "Unknown") + "</td>";
  547. html += "</tr>";
  548. });
  549. html += "</tbody></table></div></div>";
  550. }
  551. // Debug data availability
  552. console.log("Full stats object:", stats);
  553. console.log("Movie settings enabled:", ' . $showMostWatchedMovies . ');
  554. console.log("Movies data:", stats.most_watched_movies);
  555. console.log("Shows data:", stats.most_watched_shows);
  556. console.log("Music data:", stats.most_listened_music);
  557. // Most Watched Movies with Posters
  558. if (' . $showMostWatchedMovies . ' && stats.most_watched_movies && stats.most_watched_movies.length > 0) {
  559. console.log("Rendering most watched movies:", stats.most_watched_movies);
  560. html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
  561. html += "<h5><i class=\"fa fa-film text-primary\"></i> Most Watched Movies</h5>";
  562. 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;\">";
  563. 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>";
  564. stats.most_watched_movies.forEach(function(movie) {
  565. console.log("Processing movie:", movie);
  566. console.log("Movie poster_path:", movie.poster_path);
  567. console.log("Movie id:", movie.id);
  568. console.log("Movie server_id:", movie.server_id);
  569. var posterUrl = getPosterUrl(movie.poster_path, movie.id, movie.server_id);
  570. console.log("Generated posterUrl:", posterUrl);
  571. var playCount = movie.play_count || 0;
  572. var year = movie.year && movie.year !== "N/A" ? movie.year : "";
  573. var title = movie.title || "Unknown Movie";
  574. html += "<div style=\"display: inline-block; margin: 0 10px 0 0; width: 150px; vertical-align: top;\">";
  575. 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=\"" + movie.id + "\">";
  576. // Poster image container
  577. 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);\">";
  578. // Hover overlay with title and year - initially hidden
  579. 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;\">";
  580. 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>";
  581. if (year && year !== "N/A") {
  582. 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>";
  583. }
  584. html += "</div>";
  585. if (posterUrl) {
  586. html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display=\"none\"; this.nextElementSibling.style.display=\"flex\";\">";
  587. }
  588. 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;\">";
  589. html += "<i class=\"fa fa-film fa-3x\"></i>";
  590. html += "</div>";
  591. // Play count badge
  592. 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);\">";
  593. html += "<i class=\"fa fa-play\"></i> " + playCount;
  594. html += "</div>";
  595. html += "</div>";
  596. // Add CSS for hover effect - this will be applied once when the first poster is rendered
  597. if (movie === stats.most_watched_movies[0]) {
  598. html += "<style>";
  599. html += ".poster-card:hover .poster-overlay { opacity: 1 !important; }";
  600. html += ".poster-card:hover { transform: scale(1.05); z-index: 10; }";
  601. html += "</style>";
  602. }
  603. html += "</div>";
  604. // Add metadata popup elements (Organizr style)
  605. html += "<div id=\"" + movie.id + "-metadata-div\" class=\"white-popup mfp-with-anim mfp-hide\">";
  606. html += "<div class=\"col-md-8 col-md-offset-2 " + movie.id + "-metadata-info\"></div>";
  607. html += "</div>";
  608. html += "</div>";
  609. });
  610. html += "</div></div>";
  611. } else {
  612. console.log("Movies not showing because:");
  613. console.log("- Setting enabled:", ' . $showMostWatchedMovies . ');
  614. console.log("- Has data:", stats.most_watched_movies && stats.most_watched_movies.length > 0);
  615. console.log("- Data:", stats.most_watched_movies);
  616. }
  617. // Most Watched TV Shows with Posters
  618. if (' . $showMostWatchedShows . ' && stats.most_watched_shows && stats.most_watched_shows.length > 0) {
  619. html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
  620. html += "<h5><i class=\"fa fa-television text-info\"></i> Most Watched TV Shows</h5>";
  621. 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;\">";
  622. 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>";
  623. stats.most_watched_shows.forEach(function(show) {
  624. var posterUrl = getPosterUrl(show.poster_path, show.id, show.server_id);
  625. var playCount = show.play_count || 0;
  626. var year = show.year && show.year !== "N/A" ? show.year : "";
  627. var title = show.title || "Unknown Show";
  628. html += "<div style=\"display: inline-block; margin: 0 10px 0 0; width: 150px; vertical-align: top;\">";
  629. 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=\"" + show.id + "\">";
  630. // Poster image container
  631. 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);\">";
  632. // Hover overlay with title and year - initially hidden
  633. 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;\">";
  634. 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>";
  635. if (year && year !== "N/A") {
  636. 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>";
  637. }
  638. html += "</div>";
  639. if (posterUrl) {
  640. html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display=\"none\"; this.nextElementSibling.style.display=\"flex\";\">";
  641. }
  642. 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;\">";
  643. html += "<i class=\"fa fa-television fa-3x\"></i>";
  644. html += "</div>";
  645. // Play count badge
  646. 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);\">";
  647. html += "<i class=\"fa fa-play\"></i> " + playCount;
  648. html += "</div>";
  649. html += "</div>";
  650. // Add CSS for hover effect - this will be applied once when the first poster is rendered
  651. if (show === stats.most_watched_shows[0]) {
  652. html += "<style>";
  653. html += ".poster-card:hover .poster-overlay { opacity: 1 !important; }";
  654. html += ".poster-card:hover { transform: scale(1.05); z-index: 10; }";
  655. html += "</style>";
  656. }
  657. html += "</div>";
  658. // Add metadata popup elements (Organizr style)
  659. html += "<div id=\"" + show.id + "-metadata-div\" class=\"white-popup mfp-with-anim mfp-hide\">";
  660. html += "<div class=\"col-md-8 col-md-offset-2 " + show.id + "-metadata-info\"></div>";
  661. html += "</div>";
  662. html += "</div>";
  663. });
  664. html += "</div></div>";
  665. }
  666. // Most Listened Music with Cover Art
  667. if (' . $showMostListenedMusic . ' && stats.most_listened_music && stats.most_listened_music.length > 0) {
  668. html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
  669. html += "<h5><i class=\"fa fa-music text-success\"></i> Most Listened Music</h5>";
  670. html += "<div class=\"row\" style=\"margin-top: 15px;\">";
  671. stats.most_listened_music.forEach(function(music) {
  672. var posterUrl = getPosterUrl(music.poster_path || music.cover_art, music.id, music.server_id);
  673. var playCount = music.play_count || 0;
  674. var artist = music.artist || "Unknown Artist";
  675. var title = music.title || music.album || "Unknown";
  676. html += "<div class=\"col-lg-2 col-md-3 col-sm-4 col-xs-6\" style=\"margin-bottom: 20px;\">";
  677. html += "<div class=\"poster-card\" style=\"position: relative; transition: transform 0.2s ease;\">";
  678. // Cover art
  679. 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);\">";
  680. if (posterUrl) {
  681. 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\";\">";
  682. }
  683. 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;\">";
  684. html += "<i class=\"fa fa-music fa-3x\"></i>";
  685. html += "</div>";
  686. // Play count badge
  687. 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);\">";
  688. html += "<i class=\"fa fa-play\"></i> " + playCount;
  689. html += "</div>";
  690. html += "</div>";
  691. // Music info with transparent background and white text
  692. html += "<div class=\"poster-info\" style=\"padding: 12px 8px; text-align: center;\">";
  693. 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>";
  694. html += "<small style=\"color: rgba(255,255,255,0.8); text-shadow: 1px 1px 2px rgba(0,0,0,0.8);\">" + artist + "</small>";
  695. html += "</div>";
  696. html += "</div></div>";
  697. });
  698. html += "</div></div>";
  699. }
  700. if (!html) {
  701. html = "<div class=\"col-lg-12 text-center text-muted\">";
  702. html += "<i class=\"fa fa-exclamation-circle fa-3x\" style=\"margin-bottom: 10px;\"></i>";
  703. html += "<h4>No JellyStat data available</h4>";
  704. html += "<p>Check your JellyStat connection and API configuration.</p>";
  705. html += "</div>";
  706. }
  707. $("#jellystat-content").html(html);
  708. }
  709. // Auto-refresh setup
  710. var refreshInterval = ' . $refreshInterval . ';
  711. if (refreshInterval > 0) {
  712. jellyStatRefreshTimer = setInterval(function() {
  713. refreshJellyStatData();
  714. }, refreshInterval);
  715. }
  716. // Update time display every 30 seconds
  717. setInterval(updateJellyStatLastRefreshTime, 30000);
  718. // Initial load
  719. $(document).ready(function() {
  720. refreshJellyStatData();
  721. });
  722. // Cleanup timer when page unloads
  723. $(window).on("beforeunload", function() {
  724. if (jellyStatRefreshTimer) {
  725. clearInterval(jellyStatRefreshTimer);
  726. }
  727. });
  728. // JellyStat metadata popups are handled by Organizr\'s built-in metadata-get click handler
  729. // The handler will call api/v2/homepage/jellystat/metadata with the data-key value
  730. </script>
  731. ';
  732. }
  733. /**
  734. * Main function to get JellyStat data
  735. */
  736. public function getJellyStatData($options = null)
  737. {
  738. if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('main'), true)) {
  739. $this->setAPIResponse('error', 'User not approved to view this homepage item - check plugin configuration', 401);
  740. return false;
  741. }
  742. try {
  743. $url = $this->config['jellyStatURL'] ?? '';
  744. $token = $this->config['jellyStatApikey'] ?? '';
  745. $days = intval($this->config['homepageJellyStatDays'] ?? 30);
  746. if (empty($url) || empty($token)) {
  747. $this->setAPIResponse('error', 'JellyStat URL or API key not configured', 500);
  748. return false;
  749. }
  750. $stats = $this->fetchJellyStatStats($url, $token, $days);
  751. if (isset($stats['error']) && $stats['error']) {
  752. $this->setAPIResponse('error', $stats['message'], 500);
  753. return false;
  754. }
  755. $this->setAPIResponse('success', 'JellyStat data retrieved successfully', 200, $stats);
  756. return true;
  757. } catch (Exception $e) {
  758. $this->setAPIResponse('error', 'Failed to retrieve JellyStat data: ' . $e->getMessage(), 500);
  759. return false;
  760. }
  761. }
  762. /**
  763. * Fetch statistics from JellyStat API
  764. */
  765. private function fetchJellyStatStats($url, $token, $days = 30)
  766. {
  767. $disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
  768. $customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
  769. // Use internal URL for server-side API calls if configured, otherwise use main URL
  770. $internalUrl = $this->config['jellyStatInternalURL'] ?? '';
  771. $apiUrl = !empty($internalUrl) ? $internalUrl : $url;
  772. $options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
  773. $baseUrl = $this->qualifyURL($apiUrl);
  774. $stats = [
  775. 'period' => "{$days} days",
  776. 'libraries' => [],
  777. 'library_totals' => [],
  778. 'server_info' => [],
  779. 'most_watched_movies' => [],
  780. 'most_watched_shows' => [],
  781. 'most_listened_music' => []
  782. ];
  783. $mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
  784. try {
  785. // Get Library Statistics - use query parameter authentication
  786. $librariesUrl = $baseUrl . '/api/getLibraries?apiKey=' . urlencode($token);
  787. $response = Requests::get($librariesUrl, [], $options);
  788. if ($response->success) {
  789. $data = json_decode($response->body, true);
  790. if (is_array($data) && !isset($data['error'])) {
  791. // Process individual libraries
  792. $stats['libraries'] = array_map(function($lib) {
  793. return [
  794. 'name' => $lib['Name'] ?? 'Unknown Library',
  795. 'type' => $this->getCollectionTypeLabel($lib['CollectionType'] ?? 'unknown'),
  796. 'item_count' => $lib['item_count'] ?? 0,
  797. 'season_count' => $lib['season_count'] ?? 0,
  798. 'episode_count' => $lib['episode_count'] ?? 0,
  799. 'total_play_time' => $lib['total_play_time'] ? $this->formatJellyStatDuration($lib['total_play_time']) : '0 min',
  800. 'play_time_raw' => $lib['total_play_time'] ?? 0,
  801. 'collection_type' => $lib['CollectionType'] ?? 'unknown'
  802. ];
  803. }, $data);
  804. // Calculate totals across all libraries
  805. $totalItems = array_sum(array_column($data, 'item_count'));
  806. $totalSeasons = array_sum(array_column($data, 'season_count'));
  807. $totalEpisodes = array_sum(array_column($data, 'episode_count'));
  808. $totalPlayTime = array_sum(array_column($data, 'total_play_time'));
  809. // Calculate library type breakdowns
  810. $typeBreakdown = [];
  811. foreach ($data as $lib) {
  812. $type = $lib['CollectionType'] ?? 'unknown';
  813. if (!isset($typeBreakdown[$type])) {
  814. $typeBreakdown[$type] = [
  815. 'count' => 0,
  816. 'items' => 0,
  817. 'play_time' => 0,
  818. 'label' => $this->getCollectionTypeLabel($type)
  819. ];
  820. }
  821. $typeBreakdown[$type]['count']++;
  822. $typeBreakdown[$type]['items'] += $lib['item_count'] ?? 0;
  823. $typeBreakdown[$type]['play_time'] += $lib['total_play_time'] ?? 0;
  824. }
  825. $stats['library_totals'] = [
  826. 'total_libraries' => count($data),
  827. 'total_items' => $totalItems,
  828. 'total_seasons' => $totalSeasons,
  829. 'total_episodes' => $totalEpisodes,
  830. 'total_play_time' => $this->formatJellyStatDuration($totalPlayTime),
  831. 'total_play_time_raw' => $totalPlayTime,
  832. 'type_breakdown' => $typeBreakdown
  833. ];
  834. // Server information
  835. $stats['server_info'] = [
  836. 'server_id' => $data[0]['ServerId'] ?? 'Unknown',
  837. 'last_updated' => date('c')
  838. ];
  839. }
  840. }
  841. // Get History data and process to extract most watched content
  842. // Calculate the start date based on the configured days period
  843. $startDate = date('Y-m-d', strtotime("-{$days} days"));
  844. // Fetch ALL history data using pagination to ensure complete play counts
  845. $allHistoryResults = $this->fetchAllJellyStatHistory($baseUrl, $token, $startDate, $options);
  846. if (!empty($allHistoryResults)) {
  847. // Process history to get most watched content
  848. $processedData = $this->processJellyStatHistory($allHistoryResults);
  849. // Extract most watched items based on user settings
  850. if ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? false) {
  851. $stats['most_watched_movies'] = array_slice($processedData['movies'], 0, $mostWatchedCount);
  852. }
  853. if ($this->config['homepageJellyStatShowMostWatchedShows'] ?? false) {
  854. $stats['most_watched_shows'] = array_slice($processedData['shows'], 0, $mostWatchedCount);
  855. }
  856. if ($this->config['homepageJellyStatShowMostListenedMusic'] ?? false) {
  857. $stats['most_listened_music'] = array_slice($processedData['music'], 0, $mostWatchedCount);
  858. }
  859. }
  860. } catch (Exception $e) {
  861. return ['error' => true, 'message' => 'Failed to fetch JellyStat data: ' . $e->getMessage()];
  862. }
  863. return $stats;
  864. }
  865. /**
  866. * Fetch all history from JellyStat using pagination
  867. */
  868. private function fetchAllJellyStatHistory($baseUrl, $token, $startDate, $options)
  869. {
  870. $allResults = [];
  871. $page = 1;
  872. $pageSize = 1000; // API page size limit
  873. do {
  874. $historyUrl = $baseUrl . '/api/getHistory?apiKey=' . urlencode($token) .
  875. '&page=' . $page .
  876. '&size=' . $pageSize .
  877. '&startDate=' . urlencode($startDate);
  878. $response = Requests::get($historyUrl, [], $options);
  879. if (!$response->success) {
  880. // Stop if there is an error
  881. break;
  882. }
  883. $data = json_decode($response->body, true);
  884. if (!isset($data['results']) || !is_array($data['results']) || empty($data['results'])) {
  885. // No more results, break the loop
  886. break;
  887. }
  888. $allResults = array_merge($allResults, $data['results']);
  889. $page++;
  890. } while (count($data['results']) == $pageSize);
  891. return $allResults;
  892. }
  893. /**
  894. * Get human-readable label for collection type
  895. */
  896. private function getCollectionTypeLabel($type)
  897. {
  898. $labels = [
  899. 'movies' => 'Movies',
  900. 'tvshows' => 'TV Shows',
  901. 'music' => 'Music',
  902. 'mixed' => 'Mixed Content',
  903. 'unknown' => 'Other'
  904. ];
  905. return $labels[$type] ?? ucfirst($type);
  906. }
  907. /**
  908. * Format bytes to human readable format
  909. */
  910. private function formatBytes($size, $precision = 2)
  911. {
  912. if ($size == 0) return '0 B';
  913. $base = log($size, 1024);
  914. $suffixes = ['B', 'KB', 'MB', 'GB', 'TB'];
  915. return round(pow(1024, $base - floor($base)), $precision) . ' ' . $suffixes[floor($base)];
  916. }
  917. /**
  918. * Format duration for display (JellyStat specific)
  919. */
  920. private function formatJellyStatDuration($ticks)
  921. {
  922. if ($ticks == 0) return 'Unknown';
  923. // Convert ticks to seconds (ticks are in 100-nanosecond intervals)
  924. $seconds = $ticks / 10000000;
  925. if ($seconds < 3600) {
  926. return gmdate('i:s', $seconds);
  927. } else {
  928. return gmdate('H:i:s', $seconds);
  929. }
  930. }
  931. /**
  932. * Process JellyStat history data to extract most watched content
  933. */
  934. private function processJellyStatHistory($historyResults)
  935. {
  936. $processed = [
  937. 'movies' => [],
  938. 'shows' => [],
  939. 'music' => []
  940. ];
  941. // Group items by ID and count plays
  942. $itemStats = [];
  943. // Debug: Log sample of first few results to understand data structure
  944. $this->setLoggerChannel('JellyStat')->info('JellyStat History Debug: Processing ' . count($historyResults) . ' history records');
  945. if (count($historyResults) > 0) {
  946. $this->setLoggerChannel('JellyStat')->info('JellyStat Sample Record: ' . json_encode(array_slice($historyResults, 0, 3), JSON_PRETTY_PRINT));
  947. }
  948. foreach ($historyResults as $index => $result) {
  949. // Determine content type based on available data
  950. $contentType = 'unknown';
  951. $itemId = null;
  952. $title = 'Unknown';
  953. $year = null;
  954. $serverId = $result['ServerId'] ?? null;
  955. // Check if it's a TV show (has SeriesName)
  956. if (!empty($result['SeriesName'])) {
  957. $contentType = 'show';
  958. $itemId = $result['SeriesName']; // Use series name as unique identifier
  959. $title = $result['SeriesName'];
  960. // Try to extract year from multiple possible sources for TV shows
  961. // 1. Check for SeriesProductionYear or ProductionYear fields
  962. if (!empty($result['SeriesProductionYear'])) {
  963. $year = (string)$result['SeriesProductionYear'];
  964. } elseif (!empty($result['ProductionYear'])) {
  965. $year = (string)$result['ProductionYear'];
  966. } elseif (!empty($result['PremiereDate'])) {
  967. // Extract year from premiere date
  968. $year = date('Y', strtotime($result['PremiereDate']));
  969. } else {
  970. // 2. Try to extract year from series name (e.g., "Show Name (2019)")
  971. if (preg_match('/\((19|20)\d{2}\)/', $title, $matches)) {
  972. $year = trim($matches[0], '()');
  973. $title = trim(str_replace($matches[0], '', $title));
  974. } elseif (!empty($result['EpisodeName'])) {
  975. // 3. As a last resort, try to extract year from episode name
  976. $episodeTitle = $result['EpisodeName'];
  977. if (preg_match('/\b(19|20)\d{2}\b/', $episodeTitle, $matches)) {
  978. $year = $matches[0];
  979. }
  980. }
  981. }
  982. }
  983. // Check if it's a movie (has NowPlayingItemName but no SeriesName)
  984. elseif (!empty($result['NowPlayingItemName']) && empty($result['SeriesName'])) {
  985. // Determine if it's likely a movie or music based on duration or other hints
  986. $itemName = $result['NowPlayingItemName'];
  987. $duration = $result['PlaybackDuration'] ?? 0;
  988. // If duration is very short (< 10 minutes) and no video streams, likely music
  989. $hasVideo = false;
  990. if (isset($result['MediaStreams']) && is_array($result['MediaStreams'])) {
  991. foreach ($result['MediaStreams'] as $stream) {
  992. if (($stream['Type'] ?? '') === 'Video') {
  993. $hasVideo = true;
  994. break;
  995. }
  996. }
  997. }
  998. if (!$hasVideo || $duration < 600) { // Less than 10 minutes and no video = likely music
  999. $contentType = 'music';
  1000. $title = $itemName;
  1001. // For music, try to extract artist info
  1002. // Music tracks might have format like "Artist - Song" or just "Song"
  1003. } else {
  1004. $contentType = 'movie';
  1005. $title = $itemName;
  1006. // Try to extract year from multiple possible sources for movies
  1007. // 1. Check for ProductionYear field first
  1008. if (!empty($result['ProductionYear'])) {
  1009. $year = (string)$result['ProductionYear'];
  1010. } elseif (!empty($result['PremiereDate'])) {
  1011. // Extract year from premiere date
  1012. $year = date('Y', strtotime($result['PremiereDate']));
  1013. } else {
  1014. // 2. Try to extract year from movie title (e.g., "Movie Title (2019)")
  1015. if (preg_match('/\((19|20)\d{2}\)/', $title, $matches)) {
  1016. $year = trim($matches[0], '()');
  1017. $title = trim(str_replace($matches[0], '', $title));
  1018. }
  1019. }
  1020. }
  1021. $itemId = $result['NowPlayingItemId'] ?? $itemName;
  1022. }
  1023. if ($itemId && $contentType !== 'unknown') {
  1024. $key = $contentType . '_' . $itemId;
  1025. if (!isset($itemStats[$key])) {
  1026. // Extract poster/image information from JellyStat API response
  1027. $posterPath = null;
  1028. $actualItemId = null;
  1029. // Get the actual Jellyfin/Emby item ID for poster generation
  1030. // Note: JellyStat history API doesn't provide poster paths directly,
  1031. // so we'll use item IDs with JellyStat's image proxy API
  1032. if ($contentType === 'movie') {
  1033. // For movies, use the NowPlayingItemId
  1034. $actualItemId = $result['NowPlayingItemId'] ?? null;
  1035. } elseif ($contentType === 'show') {
  1036. // Debug: Log all available IDs for TV shows to understand data structure
  1037. $this->setLoggerChannel('JellyStat')->info("JellyStat TV Show Debug - Series: {$result['SeriesName']}");
  1038. $this->setLoggerChannel('JellyStat')->info("Available IDs: SeriesId=" . ($result['SeriesId'] ?? 'null') .
  1039. ", ShowId=" . ($result['ShowId'] ?? 'null') .
  1040. ", ParentId=" . ($result['ParentId'] ?? 'null') .
  1041. ", NowPlayingItemId=" . ($result['NowPlayingItemId'] ?? 'null'));
  1042. // For TV shows, be more selective about ID selection to ensure we get series posters
  1043. // Priority: SeriesId (if exists) > ShowId > NowPlayingItemId (only if it looks like series) > ParentId
  1044. $actualItemId = null;
  1045. if (!empty($result['SeriesId'])) {
  1046. // SeriesId is the most reliable for series posters
  1047. $actualItemId = $result['SeriesId'];
  1048. $this->setLoggerChannel('JellyStat')->info("Using SeriesId: {$actualItemId}");
  1049. } elseif (!empty($result['ShowId'])) {
  1050. // ShowId is also series-specific
  1051. $actualItemId = $result['ShowId'];
  1052. $this->setLoggerChannel('JellyStat')->info("Using ShowId: {$actualItemId}");
  1053. } elseif (!empty($result['NowPlayingItemId'])) {
  1054. // Try NowPlayingItemId - it might be the series ID if we're looking at series-level data
  1055. $actualItemId = $result['NowPlayingItemId'];
  1056. $this->setLoggerChannel('JellyStat')->info("Using NowPlayingItemId: {$actualItemId}");
  1057. } elseif (!empty($result['ParentId'])) {
  1058. // Last resort: ParentId (might be series, season, or library)
  1059. $actualItemId = $result['ParentId'];
  1060. $this->setLoggerChannel('JellyStat')->info("Using ParentId: {$actualItemId}");
  1061. }
  1062. if (!$actualItemId) {
  1063. $this->setLoggerChannel('JellyStat')->info("No suitable ID found for TV show: {$result['SeriesName']}");
  1064. }
  1065. } elseif ($contentType === 'music') {
  1066. // For music, use NowPlayingItemId (album/track)
  1067. $actualItemId = $result['NowPlayingItemId'] ?? null;
  1068. }
  1069. $itemStats[$key] = [
  1070. 'id' => $actualItemId ?? $itemId, // Use actual item ID if available, fallback to name-based ID
  1071. 'title' => $title,
  1072. 'type' => $contentType,
  1073. 'play_count' => 0,
  1074. 'total_duration' => 0,
  1075. 'year' => $year,
  1076. 'server_id' => $serverId,
  1077. 'poster_path' => $posterPath,
  1078. 'first_played' => $result['ActivityDateInserted'] ?? null,
  1079. 'last_played' => $result['ActivityDateInserted'] ?? null
  1080. ];
  1081. }
  1082. $itemStats[$key]['play_count']++;
  1083. $itemStats[$key]['total_duration'] += $result['PlaybackDuration'] ?? 0;
  1084. // Debug: Log each play count increment
  1085. if ($contentType === 'show') {
  1086. $this->setLoggerChannel('JellyStat')->info("Play count increment for {$title}: now {$itemStats[$key]['play_count']} (Episode: {$result['EpisodeName']}, User: {$result['UserName']}, Date: {$result['ActivityDateInserted']})");
  1087. }
  1088. // Update last played time
  1089. $currentTime = $result['ActivityDateInserted'] ?? null;
  1090. if ($currentTime && (!$itemStats[$key]['last_played'] || $currentTime > $itemStats[$key]['last_played'])) {
  1091. $itemStats[$key]['last_played'] = $currentTime;
  1092. }
  1093. // Update first played time
  1094. if ($currentTime && (!$itemStats[$key]['first_played'] || $currentTime < $itemStats[$key]['first_played'])) {
  1095. $itemStats[$key]['first_played'] = $currentTime;
  1096. }
  1097. }
  1098. }
  1099. // Separate by content type and sort by play count
  1100. foreach ($itemStats as $item) {
  1101. switch ($item['type']) {
  1102. case 'movie':
  1103. $processed['movies'][] = $item;
  1104. break;
  1105. case 'show':
  1106. $processed['shows'][] = $item;
  1107. break;
  1108. case 'music':
  1109. $processed['music'][] = $item;
  1110. break;
  1111. }
  1112. }
  1113. // Sort each category by play count (descending)
  1114. usort($processed['movies'], function($a, $b) {
  1115. return $b['play_count'] - $a['play_count'];
  1116. });
  1117. usort($processed['shows'], function($a, $b) {
  1118. return $b['play_count'] - $a['play_count'];
  1119. });
  1120. usort($processed['music'], function($a, $b) {
  1121. return $b['play_count'] - $a['play_count'];
  1122. });
  1123. return $processed;
  1124. }
  1125. }