jellystat.php 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144
  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 homepageOrderJellyStat()
  177. {
  178. if ($this->homepageItemPermissions($this->jellystatHomepagePermissions('main'))) {
  179. $displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
  180. if ($displayMode === 'iframe') {
  181. return $this->renderJellyStatIframe();
  182. } else {
  183. return $this->renderJellyStatNative();
  184. }
  185. }
  186. }
  187. private function renderJellyStatIframe()
  188. {
  189. $url = $this->config['jellyStatURL'] ?? '';
  190. $height = $this->config['homepageJellyStatIframeHeight'] ?? 800;
  191. $scrolling = ($this->config['homepageJellyStatIframeScrolling'] ?? true) ? 'auto' : 'no';
  192. return '
  193. <div id="' . __FUNCTION__ . '">
  194. <div class="white-box">
  195. <div class="white-box-header">
  196. <i class="fa fa-bar-chart"></i> JellyStat Dashboard
  197. </div>
  198. <div class="white-box-content" style="padding: 0;">
  199. <iframe
  200. src="' . htmlspecialchars($this->qualifyURL($url)) . '"
  201. width="100%"
  202. height="' . intval($height) . 'px"
  203. style="border: none; border-radius: 0 0 4px 4px;"
  204. scrolling="' . $scrolling . '"
  205. frameborder="0">
  206. <p>Your browser does not support iframes. Please visit <a href="' . htmlspecialchars($url) . '" target="_blank">JellyStat</a> directly.</p>
  207. </iframe>
  208. </div>
  209. </div>
  210. </div>';
  211. }
  212. private function renderJellyStatNative()
  213. {
  214. $refreshInterval = ($this->config['homepageJellyStatRefresh'] ?? 5) * 60000; // Convert minutes to milliseconds
  215. $days = $this->config['homepageJellyStatDays'] ?? 30;
  216. $maxItems = $this->config['homepageJellyStatMaxItems'] ?? 10;
  217. $showLibraries = ($this->config['homepageJellyStatShowLibraries'] ?? true) ? 'true' : 'false';
  218. $showUsers = ($this->config['homepageJellyStatShowUsers'] ?? true) ? 'true' : 'false';
  219. $showMostWatched = ($this->config['homepageJellyStatShowMostWatched'] ?? true) ? 'true' : 'false';
  220. $showRecentActivity = ($this->config['homepageJellyStatShowRecentActivity'] ?? true) ? 'true' : 'false';
  221. $showMostWatchedMovies = ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? true) ? 'true' : 'false';
  222. $showMostWatchedShows = ($this->config['homepageJellyStatShowMostWatchedShows'] ?? true) ? 'true' : 'false';
  223. $showMostListenedMusic = ($this->config['homepageJellyStatShowMostListenedMusic'] ?? true) ? 'true' : 'false';
  224. $mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
  225. $jellyStatUrl = htmlspecialchars($this->qualifyURL($this->config['jellyStatURL'] ?? ''), ENT_QUOTES, 'UTF-8');
  226. return '
  227. <div id="' . __FUNCTION__ . '">
  228. <div class="white-box">
  229. <div class="white-box-header">
  230. <i class="fa fa-bar-chart"></i> JellyStat Analytics
  231. <span class="pull-right">
  232. <small id="jellystat-last-update" class="text-muted"></small>
  233. <button class="btn btn-xs btn-primary" onclick="refreshJellyStatData()" title="Refresh Data">
  234. <i class="fa fa-refresh" id="jellystat-refresh-icon"></i>
  235. </button>
  236. </span>
  237. </div>
  238. <div class="white-box-content">
  239. <div class="row" id="jellystat-content">
  240. <div class="col-lg-12 text-center">
  241. <i class="fa fa-spinner fa-spin"></i> Loading JellyStat data...
  242. </div>
  243. </div>
  244. </div>
  245. </div>
  246. </div>
  247. <script>
  248. var jellyStatRefreshTimer;
  249. var jellyStatLastRefresh = 0;
  250. function refreshJellyStatData() {
  251. var refreshIcon = $("#jellystat-refresh-icon");
  252. refreshIcon.addClass("fa-spin");
  253. // Show loading state
  254. $("#jellystat-content").html("<div class=\"col-lg-12 text-center\"><i class=\"fa fa-spinner fa-spin\"></i> Loading JellyStat data...</div>");
  255. // Load JellyStat data
  256. getJellyStatData()
  257. .always(function() {
  258. refreshIcon.removeClass("fa-spin");
  259. jellyStatLastRefresh = Date.now();
  260. updateJellyStatLastRefreshTime();
  261. });
  262. }
  263. function updateJellyStatLastRefreshTime() {
  264. if (jellyStatLastRefresh > 0) {
  265. var ago = Math.floor((Date.now() - jellyStatLastRefresh) / 1000);
  266. var timeText = ago < 60 ? ago + "s ago" : Math.floor(ago / 60) + "m ago";
  267. $("#jellystat-last-update").text("Updated " + timeText);
  268. }
  269. }
  270. // Helper function to get icon for content type
  271. function getTypeIcon(collectionType) {
  272. switch(collectionType) {
  273. case "movies": return "fa-film";
  274. case "tvshows": return "fa-television";
  275. case "music": return "fa-music";
  276. case "mixed": return "fa-folder-open";
  277. default: return "fa-folder";
  278. }
  279. }
  280. // Helper function to format duration from ticks
  281. function formatJellyStatDuration(ticks) {
  282. if (!ticks || ticks === 0) return "0 min";
  283. // Convert ticks to seconds (ticks are in 100-nanosecond intervals)
  284. var seconds = ticks / 10000000;
  285. if (seconds < 60) {
  286. return Math.round(seconds) + " sec";
  287. } else if (seconds < 3600) {
  288. return Math.round(seconds / 60) + " min";
  289. } else if (seconds < 86400) {
  290. var hours = Math.floor(seconds / 3600);
  291. var minutes = Math.floor((seconds % 3600) / 60);
  292. return hours + "h " + minutes + "m";
  293. } else {
  294. var days = Math.floor(seconds / 86400);
  295. var hours = Math.floor((seconds % 86400) / 3600);
  296. return days + "d " + hours + "h";
  297. }
  298. }
  299. // Helper function to generate poster URLs from JellyStat/Jellyfin
  300. function getPosterUrl(posterPath, itemId, serverId) {
  301. console.log("getPosterUrl called with:", {posterPath, itemId, serverId});
  302. // Use external URL for frontend poster display to avoid mixed content issues
  303. var jellyStatUrl = ' . json_encode($jellyStatUrl) . ';
  304. console.log("JellyStat URL from config:", jellyStatUrl);
  305. if (!posterPath && !itemId) {
  306. console.log("No poster path or item ID provided");
  307. return null;
  308. }
  309. // If we have a poster path, process it
  310. if (posterPath) {
  311. console.log("Processing poster path:", posterPath);
  312. // If its already an absolute URL, use it directly
  313. if (posterPath.indexOf("http://") === 0 || posterPath.indexOf("https://") === 0) {
  314. console.log("Poster path is absolute URL:", posterPath);
  315. return posterPath;
  316. }
  317. // If its a relative path starting with /, prepend the JellyStat URL
  318. if (jellyStatUrl && posterPath.indexOf("/") === 0) {
  319. var fullUrl = jellyStatUrl + posterPath;
  320. console.log("Generated full URL from relative path:", fullUrl);
  321. return fullUrl;
  322. }
  323. }
  324. // If we have itemId, try to generate JellyStat image proxy URL
  325. if (itemId && jellyStatUrl) {
  326. // JellyStat uses /proxy/Items/Images/Primary endpoint
  327. // Format: /proxy/Items/Images/Primary?id={itemId}&fillWidth=200&quality=90
  328. var baseUrl = jellyStatUrl.replace(/\/+$/, ""); // Remove trailing slashes
  329. var apiUrl = baseUrl + "/proxy/Items/Images/Primary?id=" + itemId + "&fillWidth=200&quality=90";
  330. console.log("Generated JellyStat proxy image URL:", apiUrl);
  331. return apiUrl;
  332. }
  333. console.log("No valid poster URL could be generated");
  334. return null;
  335. }
  336. function getJellyStatData() {
  337. return organizrAPI2("GET", "api/v2/homepage/jellystat")
  338. .done(function(data) {
  339. console.log("JellyStat API Response:", data);
  340. if (data && data.response && data.response.result === "success" && data.response.data) {
  341. console.log("JellyStat Data:", data.response.data);
  342. renderJellyStatData(data.response.data);
  343. } else {
  344. console.error("JellyStat API Error:", data);
  345. var errorMsg = "Failed to load JellyStat data";
  346. if (data && data.response && data.response.message) {
  347. errorMsg += ": " + data.response.message;
  348. }
  349. $("#jellystat-content").html("<div class=\"col-lg-12 text-center text-danger\">" + errorMsg + "</div>");
  350. }
  351. })
  352. .fail(function(xhr, status, error) {
  353. console.error("JellyStat API Request Failed:", xhr, status, error);
  354. $("#jellystat-content").html("<div class=\"col-lg-12 text-center text-danger\">Error loading JellyStat data: " + error + "</div>");
  355. });
  356. }
  357. function renderJellyStatData(stats) {
  358. console.log("Rendering JellyStat data:", stats);
  359. var html = "";
  360. // Server Overview - Summary Stats
  361. if (stats.library_totals) {
  362. console.log("Library totals found:", stats.library_totals);
  363. html += "<div class=\"col-lg-12\" style=\"margin-bottom: 20px;\">";
  364. html += "<div class=\"row\">";
  365. // Total Libraries
  366. html += "<div class=\"col-sm-3\">";
  367. html += "<div class=\"small-box bg-blue\">";
  368. html += "<div class=\"inner\">";
  369. html += "<h3>" + (stats.library_totals.total_libraries || 0) + "</h3>";
  370. html += "<p>Libraries</p>";
  371. html += "</div>";
  372. html += "<div class=\"icon\"><i class=\"fa fa-folder\"></i></div>";
  373. html += "</div></div>";
  374. // Total Items
  375. html += "<div class=\"col-sm-3\">";
  376. html += "<div class=\"small-box bg-green\">";
  377. html += "<div class=\"inner\">";
  378. html += "<h3>" + (stats.library_totals.total_items || 0).toLocaleString() + "</h3>";
  379. html += "<p>Total Items</p>";
  380. html += "</div>";
  381. html += "<div class=\"icon\"><i class=\"fa fa-film\"></i></div>";
  382. html += "</div></div>";
  383. // Total Episodes (if any)
  384. if (stats.library_totals.total_episodes > 0) {
  385. html += "<div class=\"col-sm-3\">";
  386. html += "<div class=\"small-box bg-yellow\">";
  387. html += "<div class=\"inner\">";
  388. html += "<h3>" + (stats.library_totals.total_episodes || 0).toLocaleString() + "</h3>";
  389. html += "<p>Episodes</p>";
  390. html += "</div>";
  391. html += "<div class=\"icon\"><i class=\"fa fa-television\"></i></div>";
  392. html += "</div></div>";
  393. }
  394. // Total Play Time
  395. html += "<div class=\"col-sm-3\">";
  396. html += "<div class=\"small-box bg-red\">";
  397. html += "<div class=\"inner\">";
  398. html += "<h3 style=\"font-size: 18px;\">" + (stats.library_totals.total_play_time || "0 min") + "</h3>";
  399. html += "<p>Total Watched</p>";
  400. html += "</div>";
  401. html += "<div class=\"icon\"><i class=\"fa fa-clock-o\"></i></div>";
  402. html += "</div></div>";
  403. html += "</div></div>";
  404. }
  405. // Content Type Breakdown
  406. if (stats.library_totals && stats.library_totals.type_breakdown) {
  407. html += "<div class=\"col-lg-6\">";
  408. html += "<h5><i class=\"fa fa-pie-chart text-primary\"></i> Content Breakdown</h5>";
  409. html += "<div class=\"table-responsive\">";
  410. html += "<table class=\"table table-striped table-condensed\">";
  411. html += "<thead><tr><th>Type</th><th>Libraries</th><th>Items</th><th>Watch Time</th></tr></thead>";
  412. html += "<tbody>";
  413. Object.keys(stats.library_totals.type_breakdown).forEach(function(type) {
  414. var breakdown = stats.library_totals.type_breakdown[type];
  415. var playTimeFormatted = breakdown.play_time > 0 ? formatDuration(breakdown.play_time) : "0 min";
  416. html += "<tr>";
  417. html += "<td><strong>" + breakdown.label + "</strong></td>";
  418. html += "<td><span class=\"label label-info\">" + breakdown.count + "</span></td>";
  419. html += "<td><span class=\"label label-success\">" + breakdown.items.toLocaleString() + "</span></td>";
  420. html += "<td><small>" + playTimeFormatted + "</small></td>";
  421. html += "</tr>";
  422. });
  423. html += "</tbody></table></div></div>";
  424. }
  425. // Detailed Library Statistics
  426. if (' . $showLibraries . ' && stats.libraries && stats.libraries.length > 0) {
  427. html += "<div class=\"col-lg-6\">";
  428. html += "<h5><i class=\"fa fa-folder text-info\"></i> Library Details</h5>";
  429. html += "<div class=\"table-responsive\">";
  430. html += "<table class=\"table table-striped table-condensed\">";
  431. html += "<thead><tr><th>Library</th><th>Type</th><th>Items</th><th>Watch Time</th></tr></thead>";
  432. html += "<tbody>";
  433. stats.libraries.slice(0, ' . $maxItems . ').forEach(function(lib) {
  434. var typeIcon = getTypeIcon(lib.collection_type);
  435. html += "<tr>";
  436. html += "<td><i class=\"fa " + typeIcon + " text-muted\"></i> <strong>" + (lib.name || "Unknown Library") + "</strong></td>";
  437. html += "<td><small>" + (lib.type || "Unknown") + "</small></td>";
  438. html += "<td><span class=\"label label-primary\">" + (lib.item_count || 0).toLocaleString() + "</span>";
  439. // Show additional counts for TV libraries
  440. if (lib.episode_count > 0) {
  441. html += "<br><small class=\"text-muted\">Episodes: " + lib.episode_count.toLocaleString() + "</small>";
  442. }
  443. if (lib.season_count > 0) {
  444. html += "<br><small class=\"text-muted\">Seasons: " + lib.season_count.toLocaleString() + "</small>";
  445. }
  446. html += "</td>";
  447. html += "<td><small>" + (lib.total_play_time || "0 min") + "</small></td>";
  448. html += "</tr>";
  449. });
  450. html += "</tbody></table></div></div>";
  451. }
  452. // User Statistics
  453. if (' . $showUsers . ' && stats.users && stats.users.length > 0) {
  454. html += "<div class=\"col-lg-6\">";
  455. html += "<h5><i class=\"fa fa-users\"></i> Active Users (" + stats.users.length + " total)</h5>";
  456. html += "<div class=\"row\">";
  457. stats.users.slice(0, 8).forEach(function(user) {
  458. var lastActivity = "Never";
  459. if (user.last_activity && user.last_activity !== "0001-01-01T00:00:00.0000000Z") {
  460. var activityDate = new Date(user.last_activity);
  461. lastActivity = activityDate.toLocaleDateString();
  462. }
  463. var playCount = user.play_count || 0;
  464. html += "<div class=\"col-md-6 col-sm-6\" style=\"margin-bottom: 10px;\">";
  465. html += "<div class=\"media\">";
  466. html += "<div class=\"media-left\"><i class=\"fa fa-user fa-2x text-muted\"></i></div>";
  467. html += "<div class=\"media-body\">";
  468. html += "<h6 class=\"media-heading\">" + (user.name || "Unknown User") + " <span class=\"label label-info\">" + playCount + " plays</span></h6>";
  469. html += "<small class=\"text-muted\">Last Activity: " + lastActivity + "</small>";
  470. html += "</div></div></div>";
  471. });
  472. html += "</div></div>";
  473. }
  474. // Most Watched Content
  475. if (' . $showMostWatched . ' && stats.most_watched && stats.most_watched.length > 0) {
  476. html += "<div class=\"col-lg-12\" style=\"margin-top: 20px;\">";
  477. html += "<h5><i class=\"fa fa-star text-warning\"></i> Most Watched Content</h5>";
  478. html += "<div class=\"table-responsive\">";
  479. html += "<table class=\"table table-striped table-condensed\">";
  480. html += "<thead><tr><th>Title</th><th>Type</th><th>Plays</th><th>Runtime</th><th>Year</th></tr></thead>";
  481. html += "<tbody>";
  482. stats.most_watched.slice(0, ' . $maxItems . ').forEach(function(item) {
  483. html += "<tr>";
  484. html += "<td><strong>" + (item.title || "Unknown Title") + "</strong></td>";
  485. html += "<td>" + (item.type || "Unknown") + "</td>";
  486. html += "<td><span class=\"label label-primary\">" + (item.play_count || 0) + "</span></td>";
  487. html += "<td>" + (item.runtime || "Unknown") + "</td>";
  488. html += "<td>" + (item.year || "N/A") + "</td>";
  489. html += "</tr>";
  490. });
  491. html += "</tbody></table></div></div>";
  492. }
  493. // Recent Activity
  494. if (' . $showRecentActivity . ' && stats.recent_activity && stats.recent_activity.length > 0) {
  495. html += "<div class=\"col-lg-12\" style=\"margin-top: 20px;\">";
  496. html += "<h5><i class=\"fa fa-clock-o text-success\"></i> Recent Activity</h5>";
  497. html += "<div class=\"table-responsive\">";
  498. html += "<table class=\"table table-striped table-condensed\">";
  499. html += "<thead><tr><th>Date</th><th>User</th><th>Title</th><th>Type</th></tr></thead>";
  500. html += "<tbody>";
  501. stats.recent_activity.slice(0, ' . $maxItems . ').forEach(function(activity) {
  502. var date = new Date(activity.date);
  503. var formattedDate = date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute:"2-digit"});
  504. html += "<tr>";
  505. html += "<td><small>" + formattedDate + "</small></td>";
  506. html += "<td>" + (activity.user || "Unknown User") + "</td>";
  507. html += "<td><strong>" + (activity.title || "Unknown Title") + "</strong></td>";
  508. html += "<td>" + (activity.type || "Unknown") + "</td>";
  509. html += "</tr>";
  510. });
  511. html += "</tbody></table></div></div>";
  512. }
  513. // Debug data availability
  514. console.log("Full stats object:", stats);
  515. console.log("Movie settings enabled:", ' . $showMostWatchedMovies . ');
  516. console.log("Movies data:", stats.most_watched_movies);
  517. console.log("Shows data:", stats.most_watched_shows);
  518. console.log("Music data:", stats.most_listened_music);
  519. // Most Watched Movies with Posters
  520. if (' . $showMostWatchedMovies . ' && stats.most_watched_movies && stats.most_watched_movies.length > 0) {
  521. console.log("Rendering most watched movies:", stats.most_watched_movies);
  522. html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
  523. html += "<h5><i class=\"fa fa-film text-primary\"></i> Most Watched Movies</h5>";
  524. html += "<div class=\"row\" style=\"margin-top: 15px;\">";
  525. stats.most_watched_movies.forEach(function(movie) {
  526. console.log("Processing movie:", movie);
  527. console.log("Movie poster_path:", movie.poster_path);
  528. console.log("Movie id:", movie.id);
  529. console.log("Movie server_id:", movie.server_id);
  530. var posterUrl = getPosterUrl(movie.poster_path, movie.id, movie.server_id);
  531. console.log("Generated posterUrl:", posterUrl);
  532. var playCount = movie.play_count || 0;
  533. var year = movie.year || "N/A";
  534. var title = movie.title || "Unknown Movie";
  535. html += "<div style=\"display: inline-block; margin: 10px; width: 150px;\">";
  536. 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: 280px;\">";
  537. // Poster image container
  538. html += "<div class=\"poster-image\" style=\"position: relative; width: 150px; height: 225px; background: #e9ecef;\">";
  539. if (posterUrl) {
  540. html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display=\"none\"; this.nextElementSibling.style.display=\"flex\";\">";
  541. }
  542. 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;\">";
  543. html += "<i class=\"fa fa-film fa-3x\"></i>";
  544. html += "</div>";
  545. // Play count badge
  546. 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;\">";
  547. html += "<i class=\"fa fa-play\"></i> " + playCount;
  548. html += "</div>";
  549. html += "</div>";
  550. // Movie info
  551. html += "<div class=\"poster-info\" style=\"padding: 8px; text-align: center; height: 45px;\">";
  552. html += "<div style=\"font-size: 12px; font-weight: bold; line-height: 1.2; height: 24px; overflow: hidden;\" title=\"" + title + "\">" + title + "</div>";
  553. html += "<small class=\"text-muted\">" + year + "</small>";
  554. html += "</div>";
  555. html += "</div></div>";
  556. });
  557. html += "</div></div>";
  558. } else {
  559. console.log("Movies not showing because:");
  560. console.log("- Setting enabled:", ' . $showMostWatchedMovies . ');
  561. console.log("- Has data:", stats.most_watched_movies && stats.most_watched_movies.length > 0);
  562. console.log("- Data:", stats.most_watched_movies);
  563. }
  564. // Most Watched TV Shows with Posters
  565. if (' . $showMostWatchedShows . ' && stats.most_watched_shows && stats.most_watched_shows.length > 0) {
  566. html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
  567. html += "<h5><i class=\"fa fa-television text-info\"></i> Most Watched TV Shows</h5>";
  568. html += "<div class=\"row\" style=\"margin-top: 15px;\">";
  569. stats.most_watched_shows.forEach(function(show) {
  570. var posterUrl = getPosterUrl(show.poster_path, show.id, show.server_id);
  571. var playCount = show.play_count || 0;
  572. var year = show.year || "N/A";
  573. var title = show.title || "Unknown Show";
  574. html += "<div style=\"display: inline-block; margin: 10px; width: 150px;\">";
  575. 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: 280px;\">";
  576. // Poster image container
  577. html += "<div class=\"poster-image\" style=\"position: relative; width: 150px; height: 225px; background: #e9ecef;\">";
  578. if (posterUrl) {
  579. html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display=\"none\"; this.nextElementSibling.style.display=\"flex\";\">";
  580. }
  581. 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;\">";
  582. html += "<i class=\"fa fa-television fa-3x\"></i>";
  583. html += "</div>";
  584. // Play count badge
  585. 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;\">";
  586. html += "<i class=\"fa fa-play\"></i> " + playCount;
  587. html += "</div>";
  588. html += "</div>";
  589. // Show info
  590. html += "<div class=\"poster-info\" style=\"padding: 8px; text-align: center; height: 45px;\">";
  591. html += "<div style=\"font-size: 12px; font-weight: bold; line-height: 1.2; height: 24px; overflow: hidden;\" title=\"" + title + "\">" + title + "</div>";
  592. html += "<small class=\"text-muted\">" + year + "</small>";
  593. html += "</div>";
  594. html += "</div></div>";
  595. });
  596. html += "</div></div>";
  597. }
  598. // Most Listened Music with Cover Art
  599. if (' . $showMostListenedMusic . ' && stats.most_listened_music && stats.most_listened_music.length > 0) {
  600. html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
  601. html += "<h5><i class=\"fa fa-music text-success\"></i> Most Listened Music</h5>";
  602. html += "<div class=\"row\" style=\"margin-top: 15px;\">";
  603. stats.most_listened_music.forEach(function(music) {
  604. var posterUrl = getPosterUrl(music.poster_path || music.cover_art, music.id, music.server_id);
  605. var playCount = music.play_count || 0;
  606. var artist = music.artist || "Unknown Artist";
  607. var title = music.title || music.album || "Unknown";
  608. html += "<div class=\"col-lg-2 col-md-3 col-sm-4 col-xs-6\" style=\"margin-bottom: 20px;\">";
  609. 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); transition: transform 0.2s;\">";
  610. // Cover art
  611. html += "<div class=\"poster-image\" style=\"position: relative; padding-top: 100%; background: #e9ecef;\">";
  612. if (posterUrl) {
  613. 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\";\">";
  614. }
  615. 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;\">";
  616. html += "<i class=\"fa fa-music fa-3x\"></i>";
  617. html += "</div>";
  618. // Play count badge
  619. 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;\">";
  620. html += "<i class=\"fa fa-play\"></i> " + playCount;
  621. html += "</div>";
  622. html += "</div>";
  623. // Music info
  624. html += "<div class=\"poster-info\" style=\"padding: 12px; text-align: center;\">";
  625. 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;\" title=\"" + title + "\">" + title + "</h6>";
  626. html += "<small class=\"text-muted\">" + artist + "</small>";
  627. html += "</div>";
  628. html += "</div></div>";
  629. });
  630. html += "</div></div>";
  631. }
  632. if (!html) {
  633. html = "<div class=\"col-lg-12 text-center text-muted\">";
  634. html += "<i class=\"fa fa-exclamation-circle fa-3x\" style=\"margin-bottom: 10px;\"></i>";
  635. html += "<h4>No JellyStat data available</h4>";
  636. html += "<p>Check your JellyStat connection and API configuration.</p>";
  637. html += "</div>";
  638. }
  639. $("#jellystat-content").html(html);
  640. }
  641. // Auto-refresh setup
  642. var refreshInterval = ' . $refreshInterval . ';
  643. if (refreshInterval > 0) {
  644. jellyStatRefreshTimer = setInterval(function() {
  645. refreshJellyStatData();
  646. }, refreshInterval);
  647. }
  648. // Update time display every 30 seconds
  649. setInterval(updateJellyStatLastRefreshTime, 30000);
  650. // Initial load
  651. $(document).ready(function() {
  652. refreshJellyStatData();
  653. });
  654. // Cleanup timer when page unloads
  655. $(window).on("beforeunload", function() {
  656. if (jellyStatRefreshTimer) {
  657. clearInterval(jellyStatRefreshTimer);
  658. }
  659. });
  660. </script>
  661. ';
  662. }
  663. /**
  664. * Main function to get JellyStat data
  665. */
  666. public function getJellyStatData($options = null)
  667. {
  668. if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('main'), true)) {
  669. $this->setAPIResponse('error', 'User not approved to view this homepage item - check plugin configuration', 401);
  670. return false;
  671. }
  672. try {
  673. $url = $this->config['jellyStatURL'] ?? '';
  674. $token = $this->config['jellyStatApikey'] ?? '';
  675. $days = intval($this->config['homepageJellyStatDays'] ?? 30);
  676. if (empty($url) || empty($token)) {
  677. $this->setAPIResponse('error', 'JellyStat URL or API key not configured', 500);
  678. return false;
  679. }
  680. $stats = $this->fetchJellyStatStats($url, $token, $days);
  681. if (isset($stats['error']) && $stats['error']) {
  682. $this->setAPIResponse('error', $stats['message'], 500);
  683. return false;
  684. }
  685. $this->setAPIResponse('success', 'JellyStat data retrieved successfully', 200, $stats);
  686. return true;
  687. } catch (Exception $e) {
  688. $this->setAPIResponse('error', 'Failed to retrieve JellyStat data: ' . $e->getMessage(), 500);
  689. return false;
  690. }
  691. }
  692. /**
  693. * Fetch statistics from JellyStat API
  694. */
  695. private function fetchJellyStatStats($url, $token, $days = 30)
  696. {
  697. $disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
  698. $customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
  699. // Use internal URL for server-side API calls if configured, otherwise use main URL
  700. $internalUrl = $this->config['jellyStatInternalURL'] ?? '';
  701. $apiUrl = !empty($internalUrl) ? $internalUrl : $url;
  702. $options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
  703. $baseUrl = $this->qualifyURL($apiUrl);
  704. $stats = [
  705. 'period' => "{$days} days",
  706. 'libraries' => [],
  707. 'library_totals' => [],
  708. 'server_info' => [],
  709. 'most_watched_movies' => [],
  710. 'most_watched_shows' => [],
  711. 'most_listened_music' => []
  712. ];
  713. $mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
  714. try {
  715. // Get Library Statistics - use query parameter authentication
  716. $librariesUrl = $baseUrl . '/api/getLibraries?apiKey=' . urlencode($token);
  717. $response = Requests::get($librariesUrl, [], $options);
  718. if ($response->success) {
  719. $data = json_decode($response->body, true);
  720. if (is_array($data) && !isset($data['error'])) {
  721. // Process individual libraries
  722. $stats['libraries'] = array_map(function($lib) {
  723. return [
  724. 'name' => $lib['Name'] ?? 'Unknown Library',
  725. 'type' => $this->getCollectionTypeLabel($lib['CollectionType'] ?? 'unknown'),
  726. 'item_count' => $lib['item_count'] ?? 0,
  727. 'season_count' => $lib['season_count'] ?? 0,
  728. 'episode_count' => $lib['episode_count'] ?? 0,
  729. 'total_play_time' => $lib['total_play_time'] ? $this->formatJellyStatDuration($lib['total_play_time']) : '0 min',
  730. 'play_time_raw' => $lib['total_play_time'] ?? 0,
  731. 'collection_type' => $lib['CollectionType'] ?? 'unknown'
  732. ];
  733. }, $data);
  734. // Calculate totals across all libraries
  735. $totalItems = array_sum(array_column($data, 'item_count'));
  736. $totalSeasons = array_sum(array_column($data, 'season_count'));
  737. $totalEpisodes = array_sum(array_column($data, 'episode_count'));
  738. $totalPlayTime = array_sum(array_column($data, 'total_play_time'));
  739. // Calculate library type breakdowns
  740. $typeBreakdown = [];
  741. foreach ($data as $lib) {
  742. $type = $lib['CollectionType'] ?? 'unknown';
  743. if (!isset($typeBreakdown[$type])) {
  744. $typeBreakdown[$type] = [
  745. 'count' => 0,
  746. 'items' => 0,
  747. 'play_time' => 0,
  748. 'label' => $this->getCollectionTypeLabel($type)
  749. ];
  750. }
  751. $typeBreakdown[$type]['count']++;
  752. $typeBreakdown[$type]['items'] += $lib['item_count'] ?? 0;
  753. $typeBreakdown[$type]['play_time'] += $lib['total_play_time'] ?? 0;
  754. }
  755. $stats['library_totals'] = [
  756. 'total_libraries' => count($data),
  757. 'total_items' => $totalItems,
  758. 'total_seasons' => $totalSeasons,
  759. 'total_episodes' => $totalEpisodes,
  760. 'total_play_time' => $this->formatJellyStatDuration($totalPlayTime),
  761. 'total_play_time_raw' => $totalPlayTime,
  762. 'type_breakdown' => $typeBreakdown
  763. ];
  764. // Server information
  765. $stats['server_info'] = [
  766. 'server_id' => $data[0]['ServerId'] ?? 'Unknown',
  767. 'last_updated' => date('c')
  768. ];
  769. }
  770. }
  771. // Get History data and process to extract most watched content
  772. $historyUrl = $baseUrl . '/api/getHistory?apiKey=' . urlencode($token) . '&size=500';
  773. $response = Requests::get($historyUrl, [], $options);
  774. if ($response->success) {
  775. $historyData = json_decode($response->body, true);
  776. if (is_array($historyData) && isset($historyData['results']) && is_array($historyData['results'])) {
  777. // Process history to get most watched content
  778. $processedData = $this->processJellyStatHistory($historyData['results']);
  779. // Extract most watched items based on user settings
  780. if ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? false) {
  781. $stats['most_watched_movies'] = array_slice($processedData['movies'], 0, $mostWatchedCount);
  782. }
  783. if ($this->config['homepageJellyStatShowMostWatchedShows'] ?? false) {
  784. $stats['most_watched_shows'] = array_slice($processedData['shows'], 0, $mostWatchedCount);
  785. }
  786. if ($this->config['homepageJellyStatShowMostListenedMusic'] ?? false) {
  787. $stats['most_listened_music'] = array_slice($processedData['music'], 0, $mostWatchedCount);
  788. }
  789. }
  790. }
  791. } catch (Exception $e) {
  792. return ['error' => true, 'message' => 'Failed to fetch JellyStat data: ' . $e->getMessage()];
  793. }
  794. return $stats;
  795. }
  796. /**
  797. * Get human-readable label for collection type
  798. */
  799. private function getCollectionTypeLabel($type)
  800. {
  801. $labels = [
  802. 'movies' => 'Movies',
  803. 'tvshows' => 'TV Shows',
  804. 'music' => 'Music',
  805. 'mixed' => 'Mixed Content',
  806. 'unknown' => 'Other'
  807. ];
  808. return $labels[$type] ?? ucfirst($type);
  809. }
  810. /**
  811. * Format bytes to human readable format
  812. */
  813. private function formatBytes($size, $precision = 2)
  814. {
  815. if ($size == 0) return '0 B';
  816. $base = log($size, 1024);
  817. $suffixes = ['B', 'KB', 'MB', 'GB', 'TB'];
  818. return round(pow(1024, $base - floor($base)), $precision) . ' ' . $suffixes[floor($base)];
  819. }
  820. /**
  821. * Format duration for display (JellyStat specific)
  822. */
  823. private function formatJellyStatDuration($ticks)
  824. {
  825. if ($ticks == 0) return 'Unknown';
  826. // Convert ticks to seconds (ticks are in 100-nanosecond intervals)
  827. $seconds = $ticks / 10000000;
  828. if ($seconds < 3600) {
  829. return gmdate('i:s', $seconds);
  830. } else {
  831. return gmdate('H:i:s', $seconds);
  832. }
  833. }
  834. /**
  835. * Process JellyStat history data to extract most watched content
  836. */
  837. private function processJellyStatHistory($historyResults)
  838. {
  839. $processed = [
  840. 'movies' => [],
  841. 'shows' => [],
  842. 'music' => []
  843. ];
  844. // Group items by ID and count plays
  845. $itemStats = [];
  846. foreach ($historyResults as $result) {
  847. // Determine content type based on available data
  848. $contentType = 'unknown';
  849. $itemId = null;
  850. $title = 'Unknown';
  851. $year = null;
  852. $serverId = $result['ServerId'] ?? null;
  853. // Check if it's a TV show (has SeriesName)
  854. if (!empty($result['SeriesName'])) {
  855. $contentType = 'show';
  856. $itemId = $result['SeriesName']; // Use series name as unique identifier
  857. $title = $result['SeriesName'];
  858. // Try to extract year from episode or series data
  859. if (!empty($result['EpisodeName'])) {
  860. // This is an episode, count toward the series
  861. $episodeTitle = $result['EpisodeName'];
  862. if (preg_match('/\b(19|20)\d{2}\b/', $episodeTitle, $matches)) {
  863. $year = $matches[0];
  864. }
  865. }
  866. }
  867. // Check if it's a movie (has NowPlayingItemName but no SeriesName)
  868. elseif (!empty($result['NowPlayingItemName']) && empty($result['SeriesName'])) {
  869. // Determine if it's likely a movie or music based on duration or other hints
  870. $itemName = $result['NowPlayingItemName'];
  871. $duration = $result['PlaybackDuration'] ?? 0;
  872. // If duration is very short (< 10 minutes) and no video streams, likely music
  873. $hasVideo = false;
  874. if (isset($result['MediaStreams']) && is_array($result['MediaStreams'])) {
  875. foreach ($result['MediaStreams'] as $stream) {
  876. if (($stream['Type'] ?? '') === 'Video') {
  877. $hasVideo = true;
  878. break;
  879. }
  880. }
  881. }
  882. if (!$hasVideo || $duration < 600) { // Less than 10 minutes and no video = likely music
  883. $contentType = 'music';
  884. $title = $itemName;
  885. // For music, try to extract artist info
  886. // Music tracks might have format like "Artist - Song" or just "Song"
  887. } else {
  888. $contentType = 'movie';
  889. $title = $itemName;
  890. // Try to extract year from movie title
  891. if (preg_match('/\((19|20)\d{2}\)/', $title, $matches)) {
  892. $year = $matches[1] . $matches[2];
  893. $title = trim(str_replace($matches[0], '', $title));
  894. }
  895. }
  896. $itemId = $result['NowPlayingItemId'] ?? $itemName;
  897. }
  898. if ($itemId && $contentType !== 'unknown') {
  899. $key = $contentType . '_' . $itemId;
  900. if (!isset($itemStats[$key])) {
  901. // Extract poster/image information from JellyStat API response
  902. $posterPath = null;
  903. $actualItemId = null;
  904. // Get the actual Jellyfin/Emby item ID for poster generation
  905. // Note: JellyStat history API doesn't provide poster paths directly,
  906. // so we'll use item IDs with JellyStat's image proxy API
  907. if ($contentType === 'movie') {
  908. // For movies, use the NowPlayingItemId
  909. $actualItemId = $result['NowPlayingItemId'] ?? null;
  910. } elseif ($contentType === 'show') {
  911. // For TV shows, be more selective about ID selection to ensure we get series posters
  912. // Priority: SeriesId (if exists) > specific logic for ParentId vs NowPlayingItemId
  913. $actualItemId = null;
  914. if (!empty($result['SeriesId'])) {
  915. // SeriesId is the most reliable for series posters
  916. $actualItemId = $result['SeriesId'];
  917. } elseif (!empty($result['ShowId'])) {
  918. // ShowId is also series-specific
  919. $actualItemId = $result['ShowId'];
  920. } elseif (!empty($result['ParentId']) && !empty($result['NowPlayingItemId'])) {
  921. // When both ParentId and NowPlayingItemId exist:
  922. // - If they're different, ParentId is likely the series/season
  923. // - If they're the same, we might be at series level already
  924. if ($result['ParentId'] !== $result['NowPlayingItemId']) {
  925. // ParentId is likely series or season, prefer it over episode ID
  926. $actualItemId = $result['ParentId'];
  927. } else {
  928. // They're the same, could be series-level already
  929. $actualItemId = $result['NowPlayingItemId'];
  930. }
  931. } elseif (!empty($result['ParentId'])) {
  932. // Only ParentId available, use it (might be series or season)
  933. $actualItemId = $result['ParentId'];
  934. } else {
  935. // Fallback to NowPlayingItemId (likely episode, but better than nothing)
  936. $actualItemId = $result['NowPlayingItemId'] ?? null;
  937. }
  938. } elseif ($contentType === 'music') {
  939. // For music, use NowPlayingItemId (album/track)
  940. $actualItemId = $result['NowPlayingItemId'] ?? null;
  941. }
  942. $itemStats[$key] = [
  943. 'id' => $actualItemId ?? $itemId, // Use actual item ID if available, fallback to name-based ID
  944. 'title' => $title,
  945. 'type' => $contentType,
  946. 'play_count' => 0,
  947. 'total_duration' => 0,
  948. 'year' => $year,
  949. 'server_id' => $serverId,
  950. 'poster_path' => $posterPath,
  951. 'first_played' => $result['ActivityDateInserted'] ?? null,
  952. 'last_played' => $result['ActivityDateInserted'] ?? null
  953. ];
  954. }
  955. $itemStats[$key]['play_count']++;
  956. $itemStats[$key]['total_duration'] += $result['PlaybackDuration'] ?? 0;
  957. // Update last played time
  958. $currentTime = $result['ActivityDateInserted'] ?? null;
  959. if ($currentTime && (!$itemStats[$key]['last_played'] || $currentTime > $itemStats[$key]['last_played'])) {
  960. $itemStats[$key]['last_played'] = $currentTime;
  961. }
  962. // Update first played time
  963. if ($currentTime && (!$itemStats[$key]['first_played'] || $currentTime < $itemStats[$key]['first_played'])) {
  964. $itemStats[$key]['first_played'] = $currentTime;
  965. }
  966. }
  967. }
  968. // Separate by content type and sort by play count
  969. foreach ($itemStats as $item) {
  970. switch ($item['type']) {
  971. case 'movie':
  972. $processed['movies'][] = $item;
  973. break;
  974. case 'show':
  975. $processed['shows'][] = $item;
  976. break;
  977. case 'music':
  978. $processed['music'][] = $item;
  979. break;
  980. }
  981. }
  982. // Sort each category by play count (descending)
  983. usort($processed['movies'], function($a, $b) {
  984. return $b['play_count'] - $a['play_count'];
  985. });
  986. usort($processed['shows'], function($a, $b) {
  987. return $b['play_count'] - $a['play_count'];
  988. });
  989. usort($processed['music'], function($a, $b) {
  990. return $b['play_count'] - $a['play_count'];
  991. });
  992. return $processed;
  993. }
  994. }