jellystat.php 54 KB

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