jellystat.php 100 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881
  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->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->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->error("JellyStat API test failed on /api/getLibraries: {$firstError}");
  129. // If libraries test failed, the API key is likely invalid
  130. $this->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->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. 'metadata' => [
  174. 'enabled' => [
  175. 'homepageJellyStatEnabled'
  176. ],
  177. 'auth' => [
  178. 'homepageJellyStatAuth'
  179. ],
  180. 'not_empty' => [
  181. 'jellyStatURL'
  182. ]
  183. ]
  184. ];
  185. return $this->homepageCheckKeyPermissions($key, $permissions);
  186. }
  187. public function getJellyStatMetadata($array)
  188. {
  189. $this->info('JellyStat getJellyStatMetadata called with: ' . json_encode($array));
  190. try {
  191. // Use dedicated 'metadata' permission (lighter requirements than 'main')
  192. if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('metadata'), true)) {
  193. $this->error('JellyStat metadata: Permission check failed');
  194. $this->setAPIResponse('error', 'Not authorized for JellyStat metadata', 401);
  195. return false;
  196. }
  197. $key = $array['key'] ?? null;
  198. if (!$key) {
  199. $this->error('JellyStat metadata: No key provided');
  200. $this->setAPIResponse('error', 'JellyStat metadata key is not defined', 422);
  201. return false;
  202. }
  203. // Always get JellyStat URL for image generation (needed regardless of metadata source)
  204. $jellyStatUrl = $this->config['jellyStatURL'] ?? '';
  205. $jellyStatInternalUrl = $this->config['jellyStatInternalURL'] ?? '';
  206. // Use external URL for image URLs (to avoid mixed content issues)
  207. $jellyStatImageBaseUrl = $this->qualifyURL($jellyStatUrl);
  208. // Initialize details variable
  209. $details = null;
  210. // First, try to use Emby/Jellyfin if configured
  211. // JellyStat tracks Jellyfin/Emby servers, so we can use their metadata
  212. $useEmbyMetadata = false;
  213. $useJellyfinMetadata = false;
  214. // Check if Emby is configured and enabled
  215. if ($this->config['homepageEmbyEnabled'] && !empty($this->config['embyURL']) && !empty($this->config['embyToken'])) {
  216. $this->info('JellyStat metadata: Emby is configured, will try to use it for metadata');
  217. $useEmbyMetadata = true;
  218. }
  219. // Check if Jellyfin is configured and enabled (Jellyfin uses same backend as Emby)
  220. if ($this->config['homepageJellyfinEnabled'] && !empty($this->config['jellyfinURL']) && !empty($this->config['jellyfinToken'])) {
  221. $this->info('JellyStat metadata: Jellyfin is configured, will try to use it for metadata');
  222. $useJellyfinMetadata = true;
  223. }
  224. // Track where metadata comes from to generate correct image URLs
  225. $metadataSource = null;
  226. $mediaServerUrl = null;
  227. $mediaServerToken = null;
  228. // Try to get metadata from Emby/Jellyfin first
  229. if ($useEmbyMetadata || $useJellyfinMetadata) {
  230. $this->info('JellyStat metadata: Attempting to fetch metadata from configured media server');
  231. // Use Jellyfin preferentially if both are configured (since JellyStat is for Jellyfin)
  232. if ($useJellyfinMetadata) {
  233. // Jellyfin uses the same Emby trait, just with different config keys
  234. $mediaServerUrl = $this->qualifyURL($this->config['jellyfinURL']);
  235. $mediaServerToken = $this->config['jellyfinToken'];
  236. $disableCert = $this->config['jellyfinDisableCertCheck'] ?? false;
  237. $customCert = $this->config['jellyfinUseCustomCertificate'] ?? false;
  238. $serverType = 'jellyfin';
  239. $metadataSource = 'jellyfin';
  240. } else {
  241. $mediaServerUrl = $this->qualifyURL($this->config['embyURL']);
  242. $mediaServerToken = $this->config['embyToken'];
  243. $disableCert = $this->config['embyDisableCertCheck'] ?? false;
  244. $customCert = $this->config['embyUseCustomCertificate'] ?? false;
  245. $serverType = 'emby';
  246. $metadataSource = 'emby';
  247. }
  248. // Try to fetch metadata directly from Emby/Jellyfin
  249. try {
  250. $this->info("JellyStat metadata: Trying to fetch from {$serverType} server");
  251. // Get the item metadata directly from Emby/Jellyfin API
  252. $options = $this->requestOptions($mediaServerUrl, 60, $disableCert, $customCert);
  253. // First, get a user ID (preferably admin)
  254. $userIds = $mediaServerUrl . "/Users?api_key=" . $mediaServerToken;
  255. $response = Requests::get($userIds, [], $options);
  256. if ($response->success) {
  257. $users = json_decode($response->body, true);
  258. $userId = null;
  259. // Find an admin user
  260. foreach ($users as $user) {
  261. if (isset($user['Policy']) && isset($user['Policy']['IsAdministrator']) && $user['Policy']['IsAdministrator']) {
  262. $userId = $user['Id'];
  263. break;
  264. }
  265. }
  266. // If no admin found, use first user
  267. if (!$userId && !empty($users)) {
  268. $userId = $users[0]['Id'];
  269. }
  270. if ($userId) {
  271. // Fetch the item metadata
  272. $metadataUrl = $mediaServerUrl . '/Users/' . $userId . '/Items/' . $key . '?EnableImages=true&api_key=' . $mediaServerToken . '&Fields=Overview,People,Genres,CommunityRating,CriticRating,Studios,Taglines,ProductionYear,PremiereDate,RunTimeTicks';
  273. $metadataResponse = Requests::get($metadataUrl, [], $options);
  274. if ($metadataResponse->success) {
  275. $details = json_decode($metadataResponse->body, true);
  276. if (is_array($details) && !empty($details)) {
  277. $this->info('JellyStat metadata: Successfully fetched metadata from ' . $serverType);
  278. // Keep track that we got metadata from media server
  279. // This determines which URL to use for images
  280. }
  281. } else {
  282. $this->info('JellyStat metadata: Failed to fetch item from ' . $serverType . ' - Status: ' . $metadataResponse->status_code);
  283. // Do not clear $metadataSource; keep configured server so we can still build a link
  284. }
  285. }
  286. }
  287. } catch (\Throwable $e) {
  288. $this->info('JellyStat metadata: Exception while fetching from media server: ' . $e->getMessage());
  289. }
  290. }
  291. // If we don't have details from Emby/Jellyfin, try JellyStat's own endpoints (legacy fallback)
  292. if (!$details) {
  293. $this->info('JellyStat metadata: No metadata from media servers, trying JellyStat endpoints');
  294. // Prepare URLs and options for JellyStat
  295. $apiUrl = !empty($jellyStatInternalUrl) ? $jellyStatInternalUrl : $jellyStatUrl;
  296. $token = $this->config['jellyStatApikey'] ?? '';
  297. $disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
  298. $customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
  299. $options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
  300. // Try multiple JellyStat/proxy endpoints to get detailed item information
  301. $tryEndpoints = [];
  302. if ($token !== '') {
  303. // Try JellyStat's native item detail endpoints first
  304. $tryEndpoints[] = rtrim($this->qualifyURL($apiUrl), '/') . '/api/getItem?apiKey=' . urlencode($token) . '&id=' . urlencode($key);
  305. $tryEndpoints[] = rtrim($this->qualifyURL($apiUrl), '/') . '/api/getItemById?apiKey=' . urlencode($token) . '&id=' . urlencode($key);
  306. }
  307. // Try proxying directly to Jellyfin/Emby items endpoint
  308. $tryEndpoints[] = rtrim($this->qualifyURL($apiUrl), '/') . '/proxy/Items/' . rawurlencode($key);
  309. // Also try with Fields parameter to get comprehensive metadata
  310. $tryEndpoints[] = rtrim($this->qualifyURL($apiUrl), '/') . '/proxy/Items/' . rawurlencode($key) . '?Fields=Overview,People,Genres,CommunityRating,CriticRating,Studios,Taglines,ProductionYear,PremiereDate';
  311. foreach ($tryEndpoints as $index => $endpoint) {
  312. try {
  313. $this->info("JellyStat metadata: Trying endpoint {$index}: {$endpoint}");
  314. $resp = Requests::get($endpoint, [], $options);
  315. if ($resp->success) {
  316. $json = json_decode($resp->body, true);
  317. if (is_array($json) && !isset($json['error']) && !empty($json)) {
  318. $this->info("JellyStat metadata: Successfully fetched data from endpoint {$index}");
  319. $details = $json;
  320. break;
  321. } else {
  322. $this->info("JellyStat metadata: Endpoint {$index} returned invalid or empty data");
  323. }
  324. } else {
  325. $this->info("JellyStat metadata: Endpoint {$index} failed with status {$resp->status_code}");
  326. }
  327. } catch (\Throwable $e) {
  328. $this->info("JellyStat metadata: Endpoint {$index} threw exception: " . $e->getMessage());
  329. }
  330. }
  331. }
  332. // Initialize default values that match Emby structure
  333. $title = 'Unknown Item';
  334. $type = 'movie'; // Default to movie for better icon display
  335. $year = '';
  336. $summary = '';
  337. $tagline = '';
  338. $genres = [];
  339. $actors = [];
  340. $rating = '0';
  341. $durationMs = '0';
  342. $imageUrl = 'plugins/images/homepage/no-np.png';
  343. // Determine which base URL to use for images
  344. // If we got metadata from Emby/Jellyfin, use their URL for images
  345. // Otherwise, fall back to JellyStat proxy (which likely won't work)
  346. $imageBaseUrl = $jellyStatImageBaseUrl; // Default to JellyStat
  347. if ($metadataSource && $mediaServerUrl) {
  348. // Use the media server URL directly for images
  349. $imageBaseUrl = rtrim($mediaServerUrl, '/');
  350. $this->info("JellyStat metadata: Using {$metadataSource} server for image URLs: {$imageBaseUrl}");
  351. } else {
  352. $this->info("JellyStat metadata: Using JellyStat URL for image URLs (may not work): {$imageBaseUrl}");
  353. }
  354. if (is_array($details) && !empty($details)) {
  355. $this->info('JellyStat metadata: Processing fetched details: ' . json_encode(array_keys($details)));
  356. // Extract basic information
  357. $title = $details['Name'] ?? $details['OriginalTitle'] ?? $title;
  358. $summary = $details['Overview'] ?? $summary;
  359. // Handle taglines (can be array or string)
  360. if (isset($details['Taglines'])) {
  361. if (is_array($details['Taglines']) && !empty($details['Taglines'])) {
  362. $tagline = $details['Taglines'][0];
  363. }
  364. } else {
  365. $tagline = $details['Tagline'] ?? $tagline;
  366. }
  367. // Extract year from multiple possible sources
  368. if (isset($details['ProductionYear'])) {
  369. $year = (string)$details['ProductionYear'];
  370. } elseif (isset($details['PremiereDate'])) {
  371. $premiereDateStr = $details['PremiereDate'];
  372. if ($premiereDateStr && preg_match('/^\d{4}/', $premiereDateStr, $matches)) {
  373. $year = $matches[0];
  374. }
  375. }
  376. // Extract genres
  377. if (isset($details['Genres']) && is_array($details['Genres'])) {
  378. $genres = $details['Genres'];
  379. }
  380. // Extract rating
  381. $ratingVal = $details['CommunityRating'] ?? $details['CriticRating'] ?? null;
  382. if ($ratingVal !== null && is_numeric($ratingVal)) {
  383. $rating = (string)$ratingVal;
  384. }
  385. // Extract duration (convert from Jellyfin ticks to milliseconds)
  386. if (isset($details['RunTimeTicks']) && is_numeric($details['RunTimeTicks'])) {
  387. // Jellyfin/Emby ticks are 100-nanosecond intervals
  388. // Convert to milliseconds: ticks / 10000000 * 1000 = ticks / 10000
  389. $durationMs = (string)floor($details['RunTimeTicks'] / 10000);
  390. }
  391. // Determine content type based on Jellyfin/Emby Type field
  392. $jellyfinType = strtolower($details['Type'] ?? '');
  393. switch ($jellyfinType) {
  394. case 'movie':
  395. $type = 'movie';
  396. break;
  397. case 'series':
  398. $type = 'tv';
  399. break;
  400. case 'episode':
  401. $type = 'tv';
  402. // For episodes, use series name as title if available
  403. if (isset($details['SeriesName'])) {
  404. $title = $details['SeriesName'];
  405. }
  406. break;
  407. case 'audio':
  408. case 'musicalbum':
  409. case 'musicvideo':
  410. $type = 'music';
  411. break;
  412. case 'video':
  413. default:
  414. $type = 'movie'; // Default to movie for better display
  415. break;
  416. }
  417. // Extract cast/actors information
  418. if (isset($details['People']) && is_array($details['People'])) {
  419. $actors = [];
  420. foreach ($details['People'] as $person) {
  421. if (isset($person['Name']) && isset($person['Role']) && !empty($person['Role'])) {
  422. // Generate actor image URL using appropriate server
  423. $actorImageUrl = 'plugins/images/homepage/no-list.png';
  424. if (isset($person['Id']) && !empty($person['Id'])) {
  425. if ($metadataSource && $mediaServerToken) {
  426. // Use Emby/Jellyfin URL with authentication
  427. $actorImageUrl = $imageBaseUrl . '/Items/' . rawurlencode($person['Id']) . '/Images/Primary?fillWidth=300&quality=90&api_key=' . $mediaServerToken;
  428. } else {
  429. // Fallback to JellyStat proxy (probably won't work)
  430. $actorImageUrl = $imageBaseUrl . '/proxy/Items/' . rawurlencode($person['Id']) . '/Images/Primary?fillWidth=300&quality=90';
  431. }
  432. }
  433. $actors[] = [
  434. 'name' => (string)$person['Name'],
  435. 'role' => (string)$person['Role'],
  436. 'thumb' => $actorImageUrl
  437. ];
  438. }
  439. }
  440. }
  441. // Generate image URL for the item
  442. $itemId = $details['Id'] ?? $key;
  443. $serverId = $details['ServerId'] ?? null;
  444. // Generate image URLs based on metadata source
  445. if ($metadataSource && $mediaServerToken) {
  446. // Use Emby/Jellyfin URLs with authentication
  447. if (isset($details['ImageTags']['Primary'])) {
  448. $primaryTag = $details['ImageTags']['Primary'];
  449. $imageUrl = $imageBaseUrl . '/Items/' . rawurlencode($itemId) . '/Images/Primary?tag=' . urlencode($primaryTag) . '&fillWidth=400&quality=90&api_key=' . $mediaServerToken;
  450. } elseif (isset($details['ImageTags']['Thumb'])) {
  451. // Fallback to Thumb image
  452. $thumbTag = $details['ImageTags']['Thumb'];
  453. $imageUrl = $imageBaseUrl . '/Items/' . rawurlencode($itemId) . '/Images/Thumb?tag=' . urlencode($thumbTag) . '&fillWidth=400&quality=90&api_key=' . $mediaServerToken;
  454. } elseif (isset($details['BackdropImageTags'][0])) {
  455. // Fallback to Backdrop image
  456. $backdropTag = $details['BackdropImageTags'][0];
  457. $imageUrl = $imageBaseUrl . '/Items/' . rawurlencode($itemId) . '/Images/Backdrop?tag=' . urlencode($backdropTag) . '&fillWidth=400&quality=90&api_key=' . $mediaServerToken;
  458. } else {
  459. // Final fallback: try generic Primary image
  460. $imageUrl = $imageBaseUrl . '/Items/' . rawurlencode($itemId) . '/Images/Primary?fillWidth=400&quality=90&api_key=' . $mediaServerToken;
  461. }
  462. } else {
  463. // Fallback to JellyStat proxy URLs (probably won't work)
  464. if (isset($details['ImageTags']['Primary'])) {
  465. $primaryTag = $details['ImageTags']['Primary'];
  466. $imageUrl = $imageBaseUrl . '/proxy/Items/' . rawurlencode($itemId) . '/Images/Primary?tag=' . urlencode($primaryTag) . '&fillWidth=400&quality=90';
  467. } elseif (isset($details['ImageTags']['Thumb'])) {
  468. // Fallback to Thumb image
  469. $thumbTag = $details['ImageTags']['Thumb'];
  470. $imageUrl = $imageBaseUrl . '/proxy/Items/' . rawurlencode($itemId) . '/Images/Thumb?tag=' . urlencode($thumbTag) . '&fillWidth=400&quality=90';
  471. } elseif (isset($details['BackdropImageTags'][0])) {
  472. // Fallback to Backdrop image
  473. $backdropTag = $details['BackdropImageTags'][0];
  474. $imageUrl = $imageBaseUrl . '/proxy/Items/' . rawurlencode($itemId) . '/Images/Backdrop?tag=' . urlencode($backdropTag) . '&fillWidth=400&quality=90';
  475. } else {
  476. // Final fallback: try generic Primary image proxy
  477. $imageUrl = $this->getPosterUrl(null, $itemId, $serverId) ?: $imageUrl;
  478. }
  479. }
  480. $this->info("JellyStat metadata: Processed item - Title: {$title}, Type: {$type}, Year: {$year}, Rating: {$rating}");
  481. } else {
  482. $this->info('JellyStat metadata: No detailed metadata found, using basic fallback');
  483. // Minimal fallback when no detailed data is available
  484. $imageUrl = $this->getPosterUrl(null, $key, null) ?: $imageUrl;
  485. $tagline = 'View in JellyStat';
  486. $summary = 'This item is available in your media library. Click to view more details in JellyStat.';
  487. }
  488. // Build the item structure that matches what buildMetadata() expects
  489. $item = [
  490. 'uid' => (string)$key,
  491. 'title' => $title,
  492. 'secondaryTitle' => '', // Match Emby structure
  493. 'type' => $type,
  494. 'ratingKey' => (string)$key,
  495. 'thumb' => (string)$key,
  496. 'key' => (string)$key . '-list',
  497. 'nowPlayingThumb' => (string)$key,
  498. 'nowPlayingKey' => (string)$key . '-np',
  499. 'metadataKey' => (string)$key,
  500. 'nowPlayingImageURL' => $imageUrl,
  501. 'imageURL' => $imageUrl,
  502. 'originalImage' => $imageUrl,
  503. 'nowPlayingOriginalImage' => $imageUrl,
  504. 'address' => $this->generateMediaServerLink($metadataSource, $mediaServerUrl, $key, $serverId),
  505. 'tabName' => $this->getMediaServerTabName($metadataSource),
  506. 'openTab' => $this->shouldOpenMediaServerTab($metadataSource),
  507. 'metadata' => [
  508. 'guid' => (string)$key,
  509. 'summary' => $summary,
  510. 'rating' => $rating,
  511. 'duration' => $durationMs,
  512. 'originallyAvailableAt' => '',
  513. 'year' => $year,
  514. 'tagline' => $tagline,
  515. 'genres' => $genres,
  516. 'actors' => $actors
  517. ]
  518. ];
  519. $api = ['content' => [$item]];
  520. $this->setAPIResponse('success', null, 200, $api);
  521. return $api;
  522. } catch (\Throwable $e) {
  523. // Fail gracefully with a meaningful fallback response
  524. $this->error('JellyStat metadata exception: ' . $e->getMessage());
  525. $fallbackKey = (string)($array['key'] ?? 'unknown');
  526. // Even on failure, if a media server is configured, build a link so the Emby/Jellyfin button works
  527. $effectiveSource = $metadataSource;
  528. $effectiveUrl = $mediaServerUrl;
  529. if (!$effectiveSource) {
  530. if ($useJellyfinMetadata) {
  531. $effectiveSource = 'jellyfin';
  532. $effectiveUrl = $this->qualifyURL($this->config['jellyfinURL'] ?? '');
  533. } elseif ($useEmbyMetadata) {
  534. $effectiveSource = 'emby';
  535. $effectiveUrl = $this->qualifyURL($this->config['embyURL'] ?? '');
  536. }
  537. }
  538. $fallback = [
  539. 'uid' => $fallbackKey,
  540. 'title' => 'Media Item',
  541. 'secondaryTitle' => '',
  542. 'type' => 'movie', // Default to movie for better icon
  543. 'ratingKey' => $fallbackKey,
  544. 'thumb' => $fallbackKey,
  545. 'key' => $fallbackKey . '-list',
  546. 'nowPlayingThumb' => $fallbackKey,
  547. 'nowPlayingKey' => $fallbackKey . '-np',
  548. 'metadataKey' => $fallbackKey,
  549. 'nowPlayingImageURL' => 'plugins/images/homepage/no-np.png',
  550. 'imageURL' => 'plugins/images/homepage/no-list.png',
  551. 'originalImage' => 'plugins/images/homepage/no-list.png',
  552. 'nowPlayingOriginalImage' => 'plugins/images/homepage/no-np.png',
  553. 'address' => $this->generateMediaServerLink($effectiveSource, $effectiveUrl, $fallbackKey),
  554. 'tabName' => $this->getMediaServerTabName($effectiveSource),
  555. 'openTab' => $this->shouldOpenMediaServerTab($effectiveSource),
  556. 'metadata' => [
  557. 'guid' => $fallbackKey,
  558. 'summary' => 'This item is available in your media library. Unable to load detailed metadata at this time.',
  559. 'rating' => '0',
  560. 'duration' => '0',
  561. 'originallyAvailableAt' => '',
  562. 'year' => '',
  563. 'tagline' => 'Media Library Item',
  564. 'genres' => [],
  565. 'actors' => []
  566. ]
  567. ];
  568. $api = ['content' => [$fallback]];
  569. $this->setAPIResponse('success', null, 200, $api);
  570. return $api;
  571. }
  572. }
  573. public function homepageOrderJellyStat()
  574. {
  575. if ($this->homepageItemPermissions($this->jellystatHomepagePermissions('main'))) {
  576. $displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
  577. if ($displayMode === 'iframe') {
  578. return $this->renderJellyStatIframe();
  579. } else {
  580. return $this->renderJellyStatNative();
  581. }
  582. }
  583. }
  584. private function renderJellyStatIframe()
  585. {
  586. $url = $this->config['jellyStatURL'] ?? '';
  587. $height = $this->config['homepageJellyStatIframeHeight'] ?? 800;
  588. $scrolling = ($this->config['homepageJellyStatIframeScrolling'] ?? true) ? 'auto' : 'no';
  589. return '
  590. <div id="' . __FUNCTION__ . '">
  591. <div class="white-box">
  592. <div class="white-box-header">
  593. <i class="fa fa-bar-chart"></i> JellyStat Dashboard
  594. </div>
  595. <div class="white-box-content" style="padding: 0;">
  596. <iframe
  597. src="' . htmlspecialchars($this->qualifyURL($url)) . '"
  598. width="100%"
  599. height="' . intval($height) . 'px"
  600. style="border: none; border-radius: 0 0 4px 4px;"
  601. scrolling="' . $scrolling . '"
  602. frameborder="0">
  603. <p>Your browser does not support iframes. Please visit <a href="' . htmlspecialchars($url) . '" target="_blank">JellyStat</a> directly.</p>
  604. </iframe>
  605. </div>
  606. </div>
  607. </div>';
  608. }
  609. private function renderJellyStatNative()
  610. {
  611. $refreshInterval = ($this->config['homepageJellyStatRefresh'] ?? 5) * 60000; // Convert minutes to milliseconds
  612. $days = $this->config['homepageJellyStatDays'] ?? 30;
  613. $maxItems = $this->config['homepageJellyStatMaxItems'] ?? 10;
  614. $showLibraries = ($this->config['homepageJellyStatShowLibraries'] ?? true) ? 'true' : 'false';
  615. $showUsers = ($this->config['homepageJellyStatShowUsers'] ?? true) ? 'true' : 'false';
  616. $showMostWatched = ($this->config['homepageJellyStatShowMostWatched'] ?? true) ? 'true' : 'false';
  617. $showRecentActivity = ($this->config['homepageJellyStatShowRecentActivity'] ?? true) ? 'true' : 'false';
  618. $showMostWatchedMovies = ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? true) ? 'true' : 'false';
  619. $showMostWatchedShows = ($this->config['homepageJellyStatShowMostWatchedShows'] ?? true) ? 'true' : 'false';
  620. $showMostListenedMusic = ($this->config['homepageJellyStatShowMostListenedMusic'] ?? true) ? 'true' : 'false';
  621. $mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
  622. $jellyStatUrl = htmlspecialchars($this->qualifyURL($this->config['jellyStatURL'] ?? ''), ENT_QUOTES, 'UTF-8');
  623. return '
  624. <div id="' . __FUNCTION__ . '" style="background: transparent; border: none; padding: 20px;">
  625. <style>
  626. /* JellyStat native view cleanup for uniform look */
  627. #' . __FUNCTION__ . ' { color: #e9edf2; }
  628. #' . __FUNCTION__ . ' .js-card { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 15px; margin-bottom: 20px; }
  629. #' . __FUNCTION__ . ' .js-header { margin-bottom: 15px; border-bottom: 1px solid rgba(255,255,255,0.08); padding-bottom: 10px; }
  630. #' . __FUNCTION__ . ' .js-header h3 { margin: 0; font-weight: 600; font-size: 18px; }
  631. #' . __FUNCTION__ . ' h5 { margin: 0 0 10px; font-size: 14px; font-weight: 600; color: #cfd8e3; }
  632. #' . __FUNCTION__ . ' .small-box { background: rgba(255,255,255,0.06)!important; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 12px; min-height: 90px; }
  633. #' . __FUNCTION__ . ' .small-box .inner h3 { margin: 0; font-size: 20px; font-weight: 700; }
  634. #' . __FUNCTION__ . ' .small-box .inner p { margin: 4px 0 0; font-size: 12px; opacity: 0.85; }
  635. #' . __FUNCTION__ . ' .table { margin-bottom: 0; }
  636. #' . __FUNCTION__ . ' .table > thead > tr > th { border-color: rgba(255,255,255,0.08); color: #cfd8e3; font-size: 12px; font-weight: 600; }
  637. #' . __FUNCTION__ . ' .table > tbody > tr > td { border-color: rgba(255,255,255,0.06); font-size: 12px; }
  638. #' . __FUNCTION__ . ' .media { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 10px; }
  639. #' . __FUNCTION__ . ' .media .media-left i { width: 36px; height: 36px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.08); }
  640. #' . __FUNCTION__ . ' .media .media-heading { font-size: 13px; margin: 0 0 4px; }
  641. #' . __FUNCTION__ . ' .media small { font-size: 11px; opacity: 0.8; }
  642. #' . __FUNCTION__ . ' .poster-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; }
  643. #' . __FUNCTION__ . ' .poster-image { border-radius: 8px; }
  644. #' . __FUNCTION__ . ' .play-count-badge { background: rgba(0,0,0,0.65)!important; }
  645. </style>
  646. <div class="js-card">
  647. <div class="js-header">
  648. <h3><i class="fa fa-bar-chart"></i> JellyStat Analytics</h3>
  649. <span class="pull-right">
  650. <small id="jellystat-last-update" style="color: rgba(255,255,255,0.7);"></small>
  651. <button class="btn btn-xs btn-primary" onclick="refreshJellyStatData()" title="Refresh Data" style="margin-left: 10px;">
  652. <i class="fa fa-refresh" id="jellystat-refresh-icon"></i>
  653. </button>
  654. </span>
  655. </div>
  656. <div>
  657. <div class="row" id="jellystat-content">
  658. <div class="col-lg-12 text-center">
  659. <i class="fa fa-spinner fa-spin" style="color: white;"></i> <span style="color: white;">Loading JellyStat data...</span>
  660. </div>
  661. </div>
  662. </div>
  663. </div>
  664. </div>
  665. <script>
  666. var jellyStatRefreshTimer;
  667. var jellyStatLastRefresh = 0;
  668. function refreshJellyStatData() {
  669. var refreshIcon = $("#jellystat-refresh-icon");
  670. refreshIcon.addClass("fa-spin");
  671. // Show loading state
  672. $("#jellystat-content").html("<div class=\"col-lg-12 text-center\"><i class=\"fa fa-spinner fa-spin\"></i> Loading JellyStat data...</div>");
  673. // Load JellyStat data
  674. getJellyStatData()
  675. .always(function() {
  676. refreshIcon.removeClass("fa-spin");
  677. jellyStatLastRefresh = Date.now();
  678. updateJellyStatLastRefreshTime();
  679. });
  680. }
  681. function updateJellyStatLastRefreshTime() {
  682. if (jellyStatLastRefresh > 0) {
  683. var ago = Math.floor((Date.now() - jellyStatLastRefresh) / 1000);
  684. var timeText = ago < 60 ? ago + "s ago" : Math.floor(ago / 60) + "m ago";
  685. $("#jellystat-last-update").text("Updated " + timeText);
  686. }
  687. }
  688. // Helper function to get icon for content type
  689. function getTypeIcon(collectionType) {
  690. switch(collectionType) {
  691. case "movies": return "fa-film";
  692. case "tvshows": return "fa-television";
  693. case "music": return "fa-music";
  694. case "mixed": return "fa-folder-open";
  695. default: return "fa-folder";
  696. }
  697. }
  698. // Helper function to format duration from ticks
  699. function formatJellyStatDuration(ticks) {
  700. if (!ticks || ticks === 0) return "0 min";
  701. // Convert ticks to seconds (ticks are in 100-nanosecond intervals)
  702. var seconds = ticks / 10000000;
  703. if (seconds < 60) {
  704. return Math.round(seconds) + " sec";
  705. } else if (seconds < 3600) {
  706. return Math.round(seconds / 60) + " min";
  707. } else if (seconds < 86400) {
  708. var hours = Math.floor(seconds / 3600);
  709. var minutes = Math.floor((seconds % 3600) / 60);
  710. return hours + "h " + minutes + "m";
  711. } else {
  712. var days = Math.floor(seconds / 86400);
  713. var hours = Math.floor((seconds % 86400) / 3600);
  714. return days + "d " + hours + "h";
  715. }
  716. }
  717. // Helper function to sanitize IDs for use in HTML attributes and CSS selectors
  718. function sanitizeId(id) {
  719. if (!id) return \'jellystat-unknown\';
  720. // Convert to string and replace problematic characters
  721. return String(id)
  722. .replace(/[^a-zA-Z0-9\\-_]/g, \'_\') // Replace special chars with underscores
  723. .replace(/^[0-9]/, \'_\' + String(id).charAt(0)) // Prefix with underscore if starts with number
  724. .toLowerCase();
  725. }
  726. // Helper function to generate poster URLs from JellyStat/Jellyfin
  727. function getPosterUrl(posterPath, itemId, serverId) {
  728. console.log("getPosterUrl called with:", {posterPath, itemId, serverId});
  729. // Use external URL for frontend poster display to avoid mixed content issues
  730. var jellyStatUrl = ' . json_encode($jellyStatUrl) . ';
  731. console.log("JellyStat URL from config:", jellyStatUrl);
  732. if (!posterPath && !itemId) {
  733. console.log("No poster path or item ID provided");
  734. return null;
  735. }
  736. // If we have a poster path, process it
  737. if (posterPath) {
  738. console.log("Processing poster path:", posterPath);
  739. // If its already an absolute URL, use it directly
  740. if (posterPath.indexOf("http://") === 0 || posterPath.indexOf("https://") === 0) {
  741. console.log("Poster path is absolute URL:", posterPath);
  742. return posterPath;
  743. }
  744. // If its a relative path starting with /, prepend the JellyStat URL
  745. if (jellyStatUrl && posterPath.indexOf("/") === 0) {
  746. var fullUrl = jellyStatUrl + posterPath;
  747. console.log("Generated full URL from relative path:", fullUrl);
  748. return fullUrl;
  749. }
  750. }
  751. // If we have itemId, try to generate JellyStat image proxy URL
  752. if (itemId && jellyStatUrl) {
  753. // JellyStat uses /proxy/Items/Images/Primary endpoint
  754. // Format: /proxy/Items/Images/Primary?id={itemId}&fillWidth=200&quality=90
  755. var baseUrl = jellyStatUrl.replace(/\/+$/, ""); // Remove trailing slashes
  756. var apiUrl = baseUrl + "/proxy/Items/Images/Primary?id=" + encodeURIComponent(itemId) + "&fillWidth=200&quality=90";
  757. console.log("Generated JellyStat proxy image URL:", apiUrl);
  758. return apiUrl;
  759. }
  760. console.log("No valid poster URL could be generated");
  761. return null;
  762. }
  763. function getJellyStatData() {
  764. return organizrAPI2("GET", "api/v2/homepage/jellystat")
  765. .done(function(data) {
  766. console.log("JellyStat API Response:", data);
  767. if (data && data.response && data.response.result === "success" && data.response.data) {
  768. console.log("JellyStat Data:", data.response.data);
  769. renderJellyStatData(data.response.data);
  770. } else {
  771. console.error("JellyStat API Error:", data);
  772. var errorMsg = "Failed to load JellyStat data";
  773. if (data && data.response && data.response.message) {
  774. errorMsg += ": " + data.response.message;
  775. }
  776. $("#jellystat-content").html("<div class=\"col-lg-12 text-center text-danger\">" + errorMsg + "</div>");
  777. }
  778. })
  779. .fail(function(xhr, status, error) {
  780. console.error("JellyStat API Request Failed:", xhr, status, error);
  781. $("#jellystat-content").html("<div class=\"col-lg-12 text-center text-danger\">Error loading JellyStat data: " + error + "</div>");
  782. });
  783. }
  784. function renderJellyStatData(stats) {
  785. console.log("Rendering JellyStat data:", stats);
  786. var html = "";
  787. // Server Overview - Summary Stats
  788. if (stats.library_totals) {
  789. console.log("Library totals found:", stats.library_totals);
  790. html += "<div class=\"col-lg-12\" style=\"margin-bottom: 20px;\">";
  791. html += "<div class=\"row\">";
  792. // Total Libraries
  793. html += "<div class=\"col-sm-3\">";
  794. html += "<div class=\"small-box bg-blue\">";
  795. html += "<div class=\"inner\">";
  796. html += "<h3>" + (stats.library_totals.total_libraries || 0) + "</h3>";
  797. html += "<p>Libraries</p>";
  798. html += "</div>";
  799. html += "<div class=\"icon\"><i class=\"fa fa-folder\"></i></div>";
  800. html += "</div></div>";
  801. // Total Items
  802. html += "<div class=\"col-sm-3\">";
  803. html += "<div class=\"small-box bg-green\">";
  804. html += "<div class=\"inner\">";
  805. html += "<h3>" + (stats.library_totals.total_items || 0).toLocaleString() + "</h3>";
  806. html += "<p>Total Items</p>";
  807. html += "</div>";
  808. html += "<div class=\"icon\"><i class=\"fa fa-film\"></i></div>";
  809. html += "</div></div>";
  810. // Total Episodes (if any)
  811. if (stats.library_totals.total_episodes > 0) {
  812. html += "<div class=\"col-sm-3\">";
  813. html += "<div class=\"small-box bg-yellow\">";
  814. html += "<div class=\"inner\">";
  815. html += "<h3>" + (stats.library_totals.total_episodes || 0).toLocaleString() + "</h3>";
  816. html += "<p>Episodes</p>";
  817. html += "</div>";
  818. html += "<div class=\"icon\"><i class=\"fa fa-television\"></i></div>";
  819. html += "</div></div>";
  820. }
  821. // Total Play Time
  822. html += "<div class=\"col-sm-3\">";
  823. html += "<div class=\"small-box bg-red\">";
  824. html += "<div class=\"inner\">";
  825. html += "<h3 style=\"font-size: 18px;\">" + (stats.library_totals.total_play_time || "0 min") + "</h3>";
  826. html += "<p>Total Watched</p>";
  827. html += "</div>";
  828. html += "<div class=\"icon\"><i class=\"fa fa-clock-o\"></i></div>";
  829. html += "</div></div>";
  830. html += "</div></div>";
  831. }
  832. // Content Type Breakdown
  833. if (stats.library_totals && stats.library_totals.type_breakdown) {
  834. html += "<div class=\"col-lg-6\">";
  835. html += "<h5><i class=\"fa fa-pie-chart text-primary\"></i> Content Breakdown</h5>";
  836. html += "<div class=\"table-responsive\">";
  837. html += "<table class=\"table table-striped table-condensed\">";
  838. html += "<thead><tr><th>Type</th><th>Libraries</th><th>Items</th><th>Watch Time</th></tr></thead>";
  839. html += "<tbody>";
  840. Object.keys(stats.library_totals.type_breakdown).forEach(function(type) {
  841. var breakdown = stats.library_totals.type_breakdown[type];
  842. var playTimeFormatted = breakdown.play_time > 0 ? formatJellyStatDuration(breakdown.play_time) : "0 min";
  843. html += "<tr>";
  844. html += "<td><strong>" + breakdown.label + "</strong></td>";
  845. html += "<td><strong style=\"color: #5bc0de;\">" + breakdown.count + "</strong></td>";
  846. html += "<td><strong style=\"color: #5cb85c;\">" + breakdown.items.toLocaleString() + "</strong></td>";
  847. html += "<td><small>" + playTimeFormatted + "</small></td>";
  848. html += "</tr>";
  849. });
  850. html += "</tbody></table></div></div>";
  851. }
  852. // Detailed Library Statistics
  853. if (' . $showLibraries . ' && stats.libraries && stats.libraries.length > 0) {
  854. html += "<div class=\"col-lg-6\">";
  855. html += "<h5><i class=\"fa fa-folder text-info\"></i> Library Details</h5>";
  856. html += "<div class=\"table-responsive\">";
  857. html += "<table class=\"table table-striped table-condensed\">";
  858. html += "<thead><tr><th>Library</th><th>Type</th><th>Items</th><th>Watch Time</th></tr></thead>";
  859. html += "<tbody>";
  860. stats.libraries.slice(0, ' . $maxItems . ').forEach(function(lib) {
  861. var typeIcon = getTypeIcon(lib.collection_type);
  862. html += "<tr>";
  863. html += "<td><i class=\"fa " + typeIcon + " text-muted\"></i> <strong>" + (lib.name || "Unknown Library") + "</strong></td>";
  864. html += "<td><small>" + (lib.type || "Unknown") + "</small></td>";
  865. html += "<td><strong style=\"color: #337ab7;\">" + (lib.item_count || 0).toLocaleString() + "</strong>";
  866. // Show additional counts for TV libraries
  867. if (lib.episode_count > 0) {
  868. html += "<br><small class=\"text-muted\">Episodes: <strong style=\"color: #337ab7;\">" + lib.episode_count.toLocaleString() + "</strong></small>";
  869. }
  870. if (lib.season_count > 0) {
  871. html += "<br><small class=\"text-muted\">Seasons: <strong style=\"color: #337ab7;\">" + lib.season_count.toLocaleString() + "</strong></small>";
  872. }
  873. html += "</td>";
  874. html += "<td><small>" + (lib.total_play_time || "0 min") + "</small></td>";
  875. html += "</tr>";
  876. });
  877. html += "</tbody></table></div></div>";
  878. }
  879. // User Statistics
  880. if (' . $showUsers . ' && stats.users && stats.users.length > 0) {
  881. html += "<div class=\"col-lg-12\">";
  882. html += "<h5><i class=\"fa fa-users\"></i> Active Users (" + stats.users.length + " total)</h5>";
  883. html += "<div class=\"row\">";
  884. stats.users.slice(0, 12).forEach(function(user) {
  885. var lastActivity = "Never";
  886. if (user.last_activity && user.last_activity !== "0001-01-01T00:00:00.0000000Z") {
  887. var activityDate = new Date(user.last_activity);
  888. lastActivity = activityDate.toLocaleDateString();
  889. }
  890. var playCount = user.play_count || 0;
  891. html += "<div class=\"col-lg-3 col-md-4 col-sm-6\" style=\"margin-bottom: 15px;\">";
  892. html += "<div class=\"media\">";
  893. html += "<div class=\"media-left\"><i class=\"fa fa-user fa-2x text-muted\"></i></div>";
  894. html += "<div class=\"media-body\">";
  895. html += "<h6 class=\"media-heading\">" + (user.name || "Unknown User") + " <strong style=\"color: #5bc0de;\">" + playCount + " plays</strong></h6>";
  896. html += "<small class=\"text-muted\">Last Activity: " + lastActivity + "</small>";
  897. html += "</div></div></div>";
  898. });
  899. html += "</div></div>";
  900. }
  901. // Most Watched Content
  902. if (' . $showMostWatched . ' && stats.most_watched && stats.most_watched.length > 0) {
  903. html += "<div class=\"col-lg-12\" style=\"margin-top: 20px;\">";
  904. html += "<h5><i class=\"fa fa-star text-warning\"></i> Most Watched Content</h5>";
  905. html += "<div class=\"table-responsive\">";
  906. html += "<table class=\"table table-striped table-condensed\">";
  907. html += "<thead><tr><th>Title</th><th>Type</th><th>Plays</th><th>Runtime</th><th>Year</th></tr></thead>";
  908. html += "<tbody>";
  909. stats.most_watched.slice(0, ' . $maxItems . ').forEach(function(item) {
  910. html += "<tr>";
  911. html += "<td><strong>" + (item.title || "Unknown Title") + "</strong></td>";
  912. html += "<td>" + (item.type || "Unknown") + "</td>";
  913. html += "<td><strong style=\"color: #337ab7;\">" + (item.play_count || 0) + "</strong></td>";
  914. html += "<td>" + (item.runtime || "Unknown") + "</td>";
  915. html += "<td>" + (item.year && item.year !== "N/A" ? item.year : "") + "</td>";
  916. html += "</tr>";
  917. });
  918. html += "</tbody></table></div></div>";
  919. }
  920. // Recent Activity
  921. if (' . $showRecentActivity . ' && stats.recent_activity && stats.recent_activity.length > 0) {
  922. html += "<div class=\"col-lg-12\" style=\"margin-top: 20px;\">";
  923. html += "<h5><i class=\"fa fa-clock-o text-success\"></i> Recent Activity</h5>";
  924. html += "<div class=\"table-responsive\">";
  925. html += "<table class=\"table table-striped table-condensed\">";
  926. html += "<thead><tr><th>Date</th><th>User</th><th>Title</th><th>Type</th></tr></thead>";
  927. html += "<tbody>";
  928. stats.recent_activity.slice(0, ' . $maxItems . ').forEach(function(activity) {
  929. var date = new Date(activity.date);
  930. var formattedDate = date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute:"2-digit"});
  931. html += "<tr>";
  932. html += "<td><small>" + formattedDate + "</small></td>";
  933. html += "<td>" + (activity.user || "Unknown User") + "</td>";
  934. html += "<td><strong>" + (activity.title || "Unknown Title") + "</strong></td>";
  935. html += "<td>" + (activity.type || "Unknown") + "</td>";
  936. html += "</tr>";
  937. });
  938. html += "</tbody></table></div></div>";
  939. }
  940. // Debug data availability
  941. console.log("Full stats object:", stats);
  942. console.log("Movie settings enabled:", ' . $showMostWatchedMovies . ');
  943. console.log("Movies data:", stats.most_watched_movies);
  944. console.log("Shows data:", stats.most_watched_shows);
  945. console.log("Music data:", stats.most_listened_music);
  946. // Most Watched Movies with Posters
  947. if (' . $showMostWatchedMovies . ' && stats.most_watched_movies && stats.most_watched_movies.length > 0) {
  948. console.log("Rendering most watched movies:", stats.most_watched_movies);
  949. html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
  950. html += "<h5><i class=\"fa fa-film text-primary\"></i> Most Watched Movies</h5>";
  951. html += "<div style=\"margin-top: 15px; overflow-x: auto; overflow-y: hidden; white-space: nowrap; padding-bottom: 10px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.3) transparent;\">";
  952. html += "<style>div::-webkit-scrollbar { height: 8px; } div::-webkit-scrollbar-track { background: rgba(255,255,255,0.1); border-radius: 4px; } div::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.3); border-radius: 4px; } div::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.5); }</style>";
  953. stats.most_watched_movies.forEach(function(movie) {
  954. console.log("Processing movie:", movie);
  955. console.log("Movie poster_path:", movie.poster_path);
  956. console.log("Movie id:", movie.id);
  957. console.log("Movie server_id:", movie.server_id);
  958. var posterUrl = getPosterUrl(movie.poster_path, movie.id, movie.server_id);
  959. console.log("Generated posterUrl:", posterUrl);
  960. var playCount = movie.play_count || 0;
  961. var year = movie.year && movie.year !== "N/A" ? movie.year : "";
  962. var title = movie.title || "Unknown Movie";
  963. // Use sanitized ID for DOM elements but original ID for data attributes
  964. var sanitizedId = sanitizeId(movie.id);
  965. console.log("Using sanitized ID:", sanitizedId, "for original ID:", movie.id);
  966. html += "<div style=\"display: inline-block; margin: 0 10px 0 0; width: 150px; vertical-align: top;\">";
  967. html += "<div class=\"poster-card metadata-get\" style=\"position: relative; width: 150px; height: 225px; transition: transform 0.2s ease; cursor: pointer;\" data-source=\"jellystat\" data-key=\"" + movie.id + "\" data-uid=\"" + sanitizedId + "\">";
  968. // Poster image container
  969. html += "<div class=\"poster-image\" style=\"position: relative; width: 150px; height: 225px; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.3);\">";
  970. // Hover overlay with title and year - initially hidden
  971. html += "<div class=\"poster-overlay\" style=\"position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.8)); color: white; padding: 20px 10px 10px; opacity: 0; transition: opacity 0.3s ease; pointer-events: none;\">";
  972. html += "<div style=\"font-size: 12px; font-weight: bold; line-height: 1.3; margin-bottom: 4px; text-shadow: 1px 1px 2px rgba(0,0,0,0.9); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;\" title=\"" + title + "\">" + title + "</div>";
  973. if (year && year !== "N/A") {
  974. html += "<small style=\"color: rgba(255,255,255,0.8); text-shadow: 1px 1px 2px rgba(0,0,0,0.9); line-height: 1.2;\">" + year + "</small>";
  975. }
  976. html += "</div>";
  977. if (posterUrl) {
  978. html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display=\"none\"; this.nextElementSibling.style.display=\"flex\";\">";
  979. }
  980. 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;\">";
  981. html += "<i class=\"fa fa-film fa-3x\"></i>";
  982. html += "</div>";
  983. // Play count badge
  984. html += "<div class=\"play-count-badge\" style=\"position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px; backdrop-filter: blur(4px);\">";
  985. html += "<i class=\"fa fa-play\"></i> " + playCount;
  986. html += "</div>";
  987. html += "</div>";
  988. // Add CSS for hover effect - this will be applied once when the first poster is rendered
  989. if (movie === stats.most_watched_movies[0]) {
  990. html += "<style>";
  991. html += ".poster-card:hover .poster-overlay { opacity: 1 !important; }";
  992. html += ".poster-card:hover { transform: scale(1.05); z-index: 10; }";
  993. html += "</style>";
  994. }
  995. html += "</div>";
  996. // Add metadata popup elements (Organizr style) using sanitized ID
  997. // Include a hidden anchor to trigger Magnific Popup, matching Emby/Jellyfin implementation
  998. html += "\u003ca class=\\"inline-popups " + sanitizedId + " hidden\\" href=\\"#" + sanitizedId + "-metadata-div\\" data-effect=\\"mfp-zoom-out\\"\u003e\u003c/a\u003e";
  999. html += "\u003cdiv id=\\"" + sanitizedId + "-metadata-div\\" class=\\"white-popup mfp-with-anim mfp-hide\\"\u003e";
  1000. html += "\u003cdiv class=\\"col-md-8 col-md-offset-2 " + sanitizedId + "-metadata-info\\"\u003e\u003c/div\u003e";
  1001. html += "\u003c/div\u003e";
  1002. html += "\u003c/div\u003e";
  1003. });
  1004. html += "</div></div>";
  1005. } else {
  1006. console.log("Movies not showing because:");
  1007. console.log("- Setting enabled:", ' . $showMostWatchedMovies . ');
  1008. console.log("- Has data:", stats.most_watched_movies && stats.most_watched_movies.length > 0);
  1009. console.log("- Data:", stats.most_watched_movies);
  1010. }
  1011. // Most Watched TV Shows with Posters
  1012. if (' . $showMostWatchedShows . ' && stats.most_watched_shows && stats.most_watched_shows.length > 0) {
  1013. html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
  1014. html += "<h5><i class=\"fa fa-television text-info\"></i> Most Watched TV Shows</h5>";
  1015. html += "<div style=\"margin-top: 15px; overflow-x: auto; overflow-y: hidden; white-space: nowrap; padding-bottom: 10px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.3) transparent;\">";
  1016. html += "<style>div::-webkit-scrollbar { height: 8px; } div::-webkit-scrollbar-track { background: rgba(255,255,255,0.1); border-radius: 4px; } div::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.3); border-radius: 4px; } div::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.5); }</style>";
  1017. stats.most_watched_shows.forEach(function(show) {
  1018. var posterUrl = getPosterUrl(show.poster_path, show.id, show.server_id);
  1019. var playCount = show.play_count || 0;
  1020. var year = show.year && show.year !== "N/A" ? show.year : "";
  1021. var title = show.title || "Unknown Show";
  1022. // Use sanitized ID for DOM elements but original ID for data attributes
  1023. var sanitizedId = sanitizeId(show.id);
  1024. console.log("Using sanitized ID:", sanitizedId, "for original ID:", show.id);
  1025. html += "<div style=\"display: inline-block; margin: 0 10px 0 0; width: 150px; vertical-align: top;\">";
  1026. html += "<div class=\"poster-card metadata-get\" style=\"position: relative; width: 150px; height: 225px; transition: transform 0.2s ease; cursor: pointer;\" data-source=\"jellystat\" data-key=\"" + show.id + "\" data-uid=\"" + sanitizedId + "\">";
  1027. // Poster image container
  1028. html += "<div class=\"poster-image\" style=\"position: relative; width: 150px; height: 225px; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.3);\">";
  1029. // Hover overlay with title and year - initially hidden
  1030. html += "<div class=\"poster-overlay\" style=\"position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.8)); color: white; padding: 20px 10px 10px; opacity: 0; transition: opacity 0.3s ease; pointer-events: none;\">";
  1031. html += "<div style=\"font-size: 12px; font-weight: bold; line-height: 1.3; margin-bottom: 4px; text-shadow: 1px 1px 2px rgba(0,0,0,0.9); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;\" title=\"" + title + "\">" + title + "</div>";
  1032. if (year && year !== "N/A") {
  1033. html += "<small style=\"color: rgba(255,255,255,0.8); text-shadow: 1px 1px 2px rgba(0,0,0,0.9); line-height: 1.2;\">" + year + "</small>";
  1034. }
  1035. html += "</div>";
  1036. if (posterUrl) {
  1037. html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display=\"none\"; this.nextElementSibling.style.display=\"flex\";\">";
  1038. }
  1039. 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;\">";
  1040. html += "<i class=\"fa fa-television fa-3x\"></i>";
  1041. html += "</div>";
  1042. // Play count badge
  1043. html += "<div class=\"play-count-badge\" style=\"position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px; backdrop-filter: blur(4px);\">";
  1044. html += "<i class=\"fa fa-play\"></i> " + playCount;
  1045. html += "</div>";
  1046. html += "</div>";
  1047. // Add CSS for hover effect - this will be applied once when the first poster is rendered
  1048. if (show === stats.most_watched_shows[0]) {
  1049. html += "<style>";
  1050. html += ".poster-card:hover .poster-overlay { opacity: 1 !important; }";
  1051. html += ".poster-card:hover { transform: scale(1.05); z-index: 10; }";
  1052. html += "</style>";
  1053. }
  1054. html += "</div>";
  1055. // Add metadata popup elements (Organizr style) using sanitized ID
  1056. // Include a hidden anchor to trigger Magnific Popup, matching Emby/Jellyfin implementation
  1057. html += "\u003ca class=\\"inline-popups " + sanitizedId + " hidden\\" href=\\"#" + sanitizedId + "-metadata-div\\" data-effect=\\"mfp-zoom-out\\"\u003e\u003c/a\u003e";
  1058. html += "\u003cdiv id=\\"" + sanitizedId + "-metadata-div\\" class=\\"white-popup mfp-with-anim mfp-hide\\"\u003e";
  1059. html += "\u003cdiv class=\\"col-md-8 col-md-offset-2 " + sanitizedId + "-metadata-info\\"\u003e\u003c/div\u003e";
  1060. html += "\u003c/div\u003e";
  1061. html += "\u003c/div\u003e";
  1062. });
  1063. html += "</div></div>";
  1064. }
  1065. // Most Listened Music with Cover Art
  1066. if (' . $showMostListenedMusic . ' && stats.most_listened_music && stats.most_listened_music.length > 0) {
  1067. html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
  1068. html += "<h5><i class=\"fa fa-music text-success\"></i> Most Listened Music</h5>";
  1069. html += "<div class=\"row\" style=\"margin-top: 15px;\">";
  1070. stats.most_listened_music.forEach(function(music) {
  1071. var posterUrl = getPosterUrl(music.poster_path || music.cover_art, music.id, music.server_id);
  1072. var playCount = music.play_count || 0;
  1073. var artist = music.artist || "Unknown Artist";
  1074. var title = music.title || music.album || "Unknown";
  1075. html += "<div class=\"col-lg-2 col-md-3 col-sm-4 col-xs-6\" style=\"margin-bottom: 20px;\">";
  1076. html += "<div class=\"poster-card\" style=\"position: relative; transition: transform 0.2s ease;\">";
  1077. // Cover art
  1078. html += "<div class=\"poster-image\" style=\"position: relative; padding-top: 100%; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.3);\">";
  1079. if (posterUrl) {
  1080. 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\";\">";
  1081. }
  1082. 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;\">";
  1083. html += "<i class=\"fa fa-music fa-3x\"></i>";
  1084. html += "</div>";
  1085. // Play count badge
  1086. html += "<div class=\"play-count-badge\" style=\"position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px; backdrop-filter: blur(4px);\">";
  1087. html += "<i class=\"fa fa-play\"></i> " + playCount;
  1088. html += "</div>";
  1089. html += "</div>";
  1090. // Music info with transparent background and white text
  1091. html += "<div class=\"poster-info\" style=\"padding: 12px 8px; text-align: center;\">";
  1092. html += "<h6 style=\"margin: 0 0 4px 0; font-size: 13px; font-weight: bold; line-height: 1.2; height: 32px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; color: white; text-shadow: 1px 1px 2px rgba(0,0,0,0.8);\" title=\"" + title + "\">" + title + "</h6>";
  1093. html += "<small style=\"color: rgba(255,255,255,0.8); text-shadow: 1px 1px 2px rgba(0,0,0,0.8);\">" + artist + "</small>";
  1094. html += "</div>";
  1095. html += "</div></div>";
  1096. });
  1097. html += "</div></div>";
  1098. }
  1099. if (!html) {
  1100. html = "<div class=\"col-lg-12 text-center text-muted\">";
  1101. html += "<i class=\"fa fa-exclamation-circle fa-3x\" style=\"margin-bottom: 10px;\"></i>";
  1102. html += "<h4>No JellyStat data available</h4>";
  1103. html += "<p>Check your JellyStat connection and API configuration.</p>";
  1104. html += "</div>";
  1105. }
  1106. $("#jellystat-content").html(html);
  1107. }
  1108. // Auto-refresh setup
  1109. var refreshInterval = ' . $refreshInterval . ';
  1110. if (refreshInterval > 0) {
  1111. jellyStatRefreshTimer = setInterval(function() {
  1112. refreshJellyStatData();
  1113. }, refreshInterval);
  1114. }
  1115. // Update time display every 30 seconds
  1116. setInterval(updateJellyStatLastRefreshTime, 30000);
  1117. // Initial load
  1118. $(document).ready(function() {
  1119. refreshJellyStatData();
  1120. });
  1121. // Cleanup timer when page unloads
  1122. $(window).on("beforeunload", function() {
  1123. if (jellyStatRefreshTimer) {
  1124. clearInterval(jellyStatRefreshTimer);
  1125. }
  1126. });
  1127. // JellyStat metadata popups are handled by Organizr\'s built-in metadata-get click handler
  1128. // The handler will call api/v2/homepage/jellystat/metadata with the data-key value
  1129. </script>
  1130. ';
  1131. }
  1132. /**
  1133. * Main function to get JellyStat data
  1134. */
  1135. public function getJellyStatData($options = null)
  1136. {
  1137. if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('main'), true)) {
  1138. $this->setAPIResponse('error', 'User not approved to view this homepage item - check plugin configuration', 401);
  1139. return false;
  1140. }
  1141. try {
  1142. $url = $this->config['jellyStatURL'] ?? '';
  1143. $token = $this->config['jellyStatApikey'] ?? '';
  1144. $days = intval($this->config['homepageJellyStatDays'] ?? 30);
  1145. if (empty($url) || empty($token)) {
  1146. $this->setAPIResponse('error', 'JellyStat URL or API key not configured', 500);
  1147. return false;
  1148. }
  1149. $stats = $this->fetchJellyStatStats($url, $token, $days);
  1150. if (isset($stats['error']) && $stats['error']) {
  1151. $this->setAPIResponse('error', $stats['message'], 500);
  1152. return false;
  1153. }
  1154. $this->setAPIResponse('success', 'JellyStat data retrieved successfully', 200, $stats);
  1155. return true;
  1156. } catch (Exception $e) {
  1157. $this->setAPIResponse('error', 'Failed to retrieve JellyStat data: ' . $e->getMessage(), 500);
  1158. return false;
  1159. }
  1160. }
  1161. /**
  1162. * Fetch statistics from JellyStat API
  1163. */
  1164. private function fetchJellyStatStats($url, $token, $days = 30)
  1165. {
  1166. $disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
  1167. $customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
  1168. // Use internal URL for server-side API calls if configured, otherwise use main URL
  1169. $internalUrl = $this->config['jellyStatInternalURL'] ?? '';
  1170. $apiUrl = !empty($internalUrl) ? $internalUrl : $url;
  1171. $options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
  1172. $baseUrl = $this->qualifyURL($apiUrl);
  1173. $stats = [
  1174. 'period' => "{$days} days",
  1175. 'libraries' => [],
  1176. 'library_totals' => [],
  1177. 'server_info' => [],
  1178. 'users' => [],
  1179. 'most_watched_movies' => [],
  1180. 'most_watched_shows' => [],
  1181. 'most_listened_music' => []
  1182. ];
  1183. $mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
  1184. try {
  1185. // Get Library Statistics - use query parameter authentication
  1186. $librariesUrl = $baseUrl . '/api/getLibraries?apiKey=' . urlencode($token);
  1187. $response = Requests::get($librariesUrl, [], $options);
  1188. if ($response->success) {
  1189. $data = json_decode($response->body, true);
  1190. if (is_array($data) && !isset($data['error'])) {
  1191. // Process individual libraries
  1192. $stats['libraries'] = array_map(function($lib) {
  1193. return [
  1194. 'name' => $lib['Name'] ?? 'Unknown Library',
  1195. 'type' => $this->getCollectionTypeLabel($lib['CollectionType'] ?? 'unknown'),
  1196. 'item_count' => $lib['item_count'] ?? 0,
  1197. 'season_count' => $lib['season_count'] ?? 0,
  1198. 'episode_count' => $lib['episode_count'] ?? 0,
  1199. 'total_play_time' => $lib['total_play_time'] ? $this->formatJellyStatDuration($lib['total_play_time']) : '0 min',
  1200. 'play_time_raw' => $lib['total_play_time'] ?? 0,
  1201. 'collection_type' => $lib['CollectionType'] ?? 'unknown'
  1202. ];
  1203. }, $data);
  1204. // Calculate totals across all libraries
  1205. $totalItems = array_sum(array_column($data, 'item_count'));
  1206. $totalSeasons = array_sum(array_column($data, 'season_count'));
  1207. $totalEpisodes = array_sum(array_column($data, 'episode_count'));
  1208. $totalPlayTime = array_sum(array_column($data, 'total_play_time'));
  1209. // Calculate library type breakdowns
  1210. $typeBreakdown = [];
  1211. foreach ($data as $lib) {
  1212. $type = $lib['CollectionType'] ?? 'unknown';
  1213. if (!isset($typeBreakdown[$type])) {
  1214. $typeBreakdown[$type] = [
  1215. 'count' => 0,
  1216. 'items' => 0,
  1217. 'play_time' => 0,
  1218. 'label' => $this->getCollectionTypeLabel($type)
  1219. ];
  1220. }
  1221. $typeBreakdown[$type]['count']++;
  1222. $typeBreakdown[$type]['items'] += $lib['item_count'] ?? 0;
  1223. $typeBreakdown[$type]['play_time'] += $lib['total_play_time'] ?? 0;
  1224. }
  1225. $stats['library_totals'] = [
  1226. 'total_libraries' => count($data),
  1227. 'total_items' => $totalItems,
  1228. 'total_seasons' => $totalSeasons,
  1229. 'total_episodes' => $totalEpisodes,
  1230. 'total_play_time' => $this->formatJellyStatDuration($totalPlayTime),
  1231. 'total_play_time_raw' => $totalPlayTime,
  1232. 'type_breakdown' => $typeBreakdown
  1233. ];
  1234. // Server information
  1235. $stats['server_info'] = [
  1236. 'server_id' => $data[0]['ServerId'] ?? 'Unknown',
  1237. 'last_updated' => date('c')
  1238. ];
  1239. }
  1240. }
  1241. // Get History data and process to extract most watched content
  1242. // Calculate the start date based on the configured days period
  1243. $startDate = date('Y-m-d', strtotime("-{$days} days"));
  1244. // Fetch ALL history data using pagination to ensure complete play counts
  1245. $allHistoryResults = $this->fetchAllJellyStatHistory($baseUrl, $token, $startDate, $options);
  1246. if (!empty($allHistoryResults)) {
  1247. // Process history to get most watched content
  1248. $processedData = $this->processJellyStatHistory($allHistoryResults);
  1249. // Extract most watched items based on user settings
  1250. if ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? false) {
  1251. $stats['most_watched_movies'] = array_slice($processedData['movies'], 0, $mostWatchedCount);
  1252. }
  1253. if ($this->config['homepageJellyStatShowMostWatchedShows'] ?? false) {
  1254. $stats['most_watched_shows'] = array_slice($processedData['shows'], 0, $mostWatchedCount);
  1255. }
  1256. if ($this->config['homepageJellyStatShowMostListenedMusic'] ?? false) {
  1257. $stats['most_listened_music'] = array_slice($processedData['music'], 0, $mostWatchedCount);
  1258. }
  1259. // Aggregate user activity statistics for frontend Active Users section
  1260. $stats['users'] = $this->aggregateJellyStatUsers($allHistoryResults);
  1261. }
  1262. } catch (Exception $e) {
  1263. return ['error' => true, 'message' => 'Failed to fetch JellyStat data: ' . $e->getMessage()];
  1264. }
  1265. return $stats;
  1266. }
  1267. /**
  1268. * Fetch all history from JellyStat using pagination
  1269. */
  1270. private function fetchAllJellyStatHistory($baseUrl, $token, $startDate, $options)
  1271. {
  1272. $allResults = [];
  1273. $page = 1;
  1274. $pageSize = 1000; // API page size limit
  1275. do {
  1276. $historyUrl = $baseUrl . '/api/getHistory?apiKey=' . urlencode($token) .
  1277. '&page=' . $page .
  1278. '&size=' . $pageSize .
  1279. '&startDate=' . urlencode($startDate);
  1280. $response = Requests::get($historyUrl, [], $options);
  1281. if (!$response->success) {
  1282. // Stop if there is an error
  1283. break;
  1284. }
  1285. $data = json_decode($response->body, true);
  1286. if (!isset($data['results']) || !is_array($data['results']) || empty($data['results'])) {
  1287. // No more results, break the loop
  1288. break;
  1289. }
  1290. $allResults = array_merge($allResults, $data['results']);
  1291. $page++;
  1292. } while (count($data['results']) == $pageSize);
  1293. return $allResults;
  1294. }
  1295. /**
  1296. * Generate poster URL from JellyStat API
  1297. */
  1298. private function getPosterUrl($posterPath, $itemId, $serverId)
  1299. {
  1300. // Use main URL for poster display (not internal URL)
  1301. $jellyStatUrl = $this->qualifyURL($this->config['jellyStatURL'] ?? '');
  1302. if (!$jellyStatUrl) {
  1303. return null;
  1304. }
  1305. // If we have a poster path, process it
  1306. if ($posterPath) {
  1307. // If its already an absolute URL, use it directly
  1308. if (strpos($posterPath, 'http://') === 0 || strpos($posterPath, 'https://') === 0) {
  1309. return $posterPath;
  1310. }
  1311. // If its a relative path starting with /, prepend the JellyStat URL
  1312. if (strpos($posterPath, '/') === 0) {
  1313. return rtrim($jellyStatUrl, '/') . $posterPath;
  1314. }
  1315. }
  1316. // If we have itemId, try to generate JellyStat image proxy URL
  1317. if ($itemId) {
  1318. // JellyStat uses /proxy/Items/Images/Primary endpoint
  1319. // Format: /proxy/Items/Images/Primary?id={itemId}&fillWidth=200&quality=90
  1320. $baseUrl = rtrim($jellyStatUrl, '/');
  1321. return $baseUrl . '/proxy/Items/Images/Primary?id=' . urlencode($itemId) . '&fillWidth=200&quality=90';
  1322. }
  1323. return null;
  1324. }
  1325. /**
  1326. * Get human-readable label for collection type
  1327. */
  1328. private function getCollectionTypeLabel($type)
  1329. {
  1330. $labels = [
  1331. 'movies' => 'Movies',
  1332. 'tvshows' => 'TV Shows',
  1333. 'music' => 'Music',
  1334. 'mixed' => 'Mixed Content',
  1335. 'unknown' => 'Other'
  1336. ];
  1337. return $labels[$type] ?? ucfirst($type);
  1338. }
  1339. /**
  1340. * Format bytes to human readable format
  1341. */
  1342. private function formatBytes($size, $precision = 2)
  1343. {
  1344. if ($size == 0) return '0 B';
  1345. $base = log($size, 1024);
  1346. $suffixes = ['B', 'KB', 'MB', 'GB', 'TB'];
  1347. return round(pow(1024, $base - floor($base)), $precision) . ' ' . $suffixes[floor($base)];
  1348. }
  1349. /**
  1350. * Format duration for display (JellyStat specific)
  1351. */
  1352. private function formatJellyStatDuration($ticks)
  1353. {
  1354. if ($ticks == 0) return 'Unknown';
  1355. // Convert ticks to seconds (ticks are in 100-nanosecond intervals)
  1356. $seconds = $ticks / 10000000;
  1357. if ($seconds < 3600) {
  1358. return gmdate('i:s', $seconds);
  1359. } else {
  1360. return gmdate('H:i:s', $seconds);
  1361. }
  1362. }
  1363. /**
  1364. * Generate media server link for metadata popup
  1365. */
  1366. private function generateMediaServerLink($metadataSource, $mediaServerUrl, $itemId, $serverId = null)
  1367. {
  1368. if (!$metadataSource || !$mediaServerUrl) {
  1369. // If we don't know the source, return empty (no link)
  1370. return '';
  1371. }
  1372. // Check if we have proper Emby/Jellyfin configuration
  1373. if ($metadataSource === 'jellyfin' && $this->config['homepageJellyfinLink']) {
  1374. $variablesForLink = [
  1375. '{id}' => $itemId,
  1376. '{serverId}' => $serverId ?? ''
  1377. ];
  1378. return $this->userDefinedIdReplacementLink($this->config['homepageJellyfinLink'], $variablesForLink);
  1379. } elseif ($metadataSource === 'emby' && $this->config['homepageEmbyLink']) {
  1380. $variablesForLink = [
  1381. '{id}' => $itemId,
  1382. '{serverId}' => $serverId ?? ''
  1383. ];
  1384. return $this->userDefinedIdReplacementLink($this->config['homepageEmbyLink'], $variablesForLink);
  1385. }
  1386. // Fallback to direct URL if no custom link configured
  1387. $baseUrl = rtrim($mediaServerUrl, '/');
  1388. return $baseUrl . '/web/index.html#!/item?id=' . $itemId . '&serverId=' . ($serverId ?? '');
  1389. }
  1390. /**
  1391. * Get the tab name for the media server
  1392. */
  1393. private function getMediaServerTabName($metadataSource)
  1394. {
  1395. if (!$metadataSource) {
  1396. return '';
  1397. }
  1398. if ($metadataSource === 'jellyfin') {
  1399. return $this->config['jellyfinTabName'] ?? '';
  1400. } elseif ($metadataSource === 'emby') {
  1401. return $this->config['embyTabName'] ?? '';
  1402. }
  1403. return '';
  1404. }
  1405. /**
  1406. * Check if we should open media server tab
  1407. */
  1408. private function shouldOpenMediaServerTab($metadataSource)
  1409. {
  1410. if (!$metadataSource) {
  1411. return false;
  1412. }
  1413. if ($metadataSource === 'jellyfin') {
  1414. return ($this->config['jellyfinTabURL'] && $this->config['jellyfinTabName']) ? true : false;
  1415. } elseif ($metadataSource === 'emby') {
  1416. return ($this->config['embyTabURL'] && $this->config['embyTabName']) ? true : false;
  1417. }
  1418. return false;
  1419. }
  1420. /**
  1421. * Process JellyStat history data to extract most watched content
  1422. */
  1423. private function processJellyStatHistory($historyResults)
  1424. {
  1425. $processed = [
  1426. 'movies' => [],
  1427. 'shows' => [],
  1428. 'music' => []
  1429. ];
  1430. // Group items by ID and count plays
  1431. $itemStats = [];
  1432. // Debug: Log sample of first few results to understand data structure
  1433. $this->setLoggerChannel('JellyStat')->info('JellyStat History Debug: Processing ' . count($historyResults) . ' history records');
  1434. if (count($historyResults) > 0) {
  1435. $this->setLoggerChannel('JellyStat')->info('JellyStat Sample Record: ' . json_encode(array_slice($historyResults, 0, 3), JSON_PRETTY_PRINT));
  1436. }
  1437. foreach ($historyResults as $index => $result) {
  1438. // Determine content type based on available data
  1439. $contentType = 'unknown';
  1440. $itemId = null;
  1441. $title = 'Unknown';
  1442. $year = null;
  1443. $serverId = $result['ServerId'] ?? null;
  1444. // Check if it's a TV show (has SeriesName)
  1445. if (!empty($result['SeriesName'])) {
  1446. $contentType = 'show';
  1447. $itemId = $result['SeriesName']; // Use series name as unique identifier
  1448. $title = $result['SeriesName'];
  1449. // Try to extract year from multiple possible sources for TV shows
  1450. // 1. Check for SeriesProductionYear or ProductionYear fields
  1451. if (!empty($result['SeriesProductionYear'])) {
  1452. $year = (string)$result['SeriesProductionYear'];
  1453. } elseif (!empty($result['ProductionYear'])) {
  1454. $year = (string)$result['ProductionYear'];
  1455. } elseif (!empty($result['PremiereDate'])) {
  1456. // Extract year from premiere date
  1457. $year = date('Y', strtotime($result['PremiereDate']));
  1458. } else {
  1459. // 2. Try to extract year from series name (e.g., "Show Name (2019)")
  1460. if (preg_match('/\((19|20)\d{2}\)/', $title, $matches)) {
  1461. $year = trim($matches[0], '()');
  1462. $title = trim(str_replace($matches[0], '', $title));
  1463. } elseif (!empty($result['EpisodeName'])) {
  1464. // 3. As a last resort, try to extract year from episode name
  1465. $episodeTitle = $result['EpisodeName'];
  1466. if (preg_match('/\b(19|20)\d{2}\b/', $episodeTitle, $matches)) {
  1467. $year = $matches[0];
  1468. }
  1469. }
  1470. }
  1471. }
  1472. // Check if it's a movie (has NowPlayingItemName but no SeriesName)
  1473. elseif (!empty($result['NowPlayingItemName']) && empty($result['SeriesName'])) {
  1474. // Determine if it's likely a movie or music based on duration or other hints
  1475. $itemName = $result['NowPlayingItemName'];
  1476. $duration = $result['PlaybackDuration'] ?? 0;
  1477. // If duration is very short (< 10 minutes) and no video streams, likely music
  1478. $hasVideo = false;
  1479. if (isset($result['MediaStreams']) && is_array($result['MediaStreams'])) {
  1480. foreach ($result['MediaStreams'] as $stream) {
  1481. if (($stream['Type'] ?? '') === 'Video') {
  1482. $hasVideo = true;
  1483. break;
  1484. }
  1485. }
  1486. }
  1487. if (!$hasVideo || $duration < 600) { // Less than 10 minutes and no video = likely music
  1488. $contentType = 'music';
  1489. $title = $itemName;
  1490. // For music, try to extract artist info
  1491. // Music tracks might have format like "Artist - Song" or just "Song"
  1492. } else {
  1493. $contentType = 'movie';
  1494. $title = $itemName;
  1495. // Try to extract year from multiple possible sources for movies
  1496. // 1. Check for ProductionYear field first
  1497. if (!empty($result['ProductionYear'])) {
  1498. $year = (string)$result['ProductionYear'];
  1499. } elseif (!empty($result['PremiereDate'])) {
  1500. // Extract year from premiere date
  1501. $year = date('Y', strtotime($result['PremiereDate']));
  1502. } else {
  1503. // 2. Try to extract year from movie title (e.g., "Movie Title (2019)")
  1504. if (preg_match('/\((19|20)\d{2}\)/', $title, $matches)) {
  1505. $year = trim($matches[0], '()');
  1506. $title = trim(str_replace($matches[0], '', $title));
  1507. }
  1508. }
  1509. }
  1510. $itemId = $result['NowPlayingItemId'] ?? $itemName;
  1511. }
  1512. if ($itemId && $contentType !== 'unknown') {
  1513. $key = $contentType . '_' . $itemId;
  1514. if (!isset($itemStats[$key])) {
  1515. // Extract poster/image information from JellyStat API response
  1516. $posterPath = null;
  1517. $actualItemId = null;
  1518. // Get the actual Jellyfin/Emby item ID for poster generation
  1519. // Note: JellyStat history API doesn't provide poster paths directly,
  1520. // so we'll use item IDs with JellyStat's image proxy API
  1521. if ($contentType === 'movie') {
  1522. // For movies, use the NowPlayingItemId
  1523. $actualItemId = $result['NowPlayingItemId'] ?? null;
  1524. } elseif ($contentType === 'show') {
  1525. // Debug: Log all available IDs for TV shows to understand data structure
  1526. $this->setLoggerChannel('JellyStat')->info("JellyStat TV Show Debug - Series: {$result['SeriesName']}");
  1527. $this->setLoggerChannel('JellyStat')->info("Available IDs: SeriesId=" . ($result['SeriesId'] ?? 'null') .
  1528. ", ShowId=" . ($result['ShowId'] ?? 'null') .
  1529. ", ParentId=" . ($result['ParentId'] ?? 'null') .
  1530. ", NowPlayingItemId=" . ($result['NowPlayingItemId'] ?? 'null'));
  1531. // For TV shows, be more selective about ID selection to ensure we get series posters
  1532. // Priority: SeriesId (if exists) > ShowId > NowPlayingItemId (only if it looks like series) > ParentId
  1533. $actualItemId = null;
  1534. if (!empty($result['SeriesId'])) {
  1535. // SeriesId is the most reliable for series posters
  1536. $actualItemId = $result['SeriesId'];
  1537. $this->setLoggerChannel('JellyStat')->info("Using SeriesId: {$actualItemId}");
  1538. } elseif (!empty($result['ShowId'])) {
  1539. // ShowId is also series-specific
  1540. $actualItemId = $result['ShowId'];
  1541. $this->setLoggerChannel('JellyStat')->info("Using ShowId: {$actualItemId}");
  1542. } elseif (!empty($result['NowPlayingItemId'])) {
  1543. // Try NowPlayingItemId - it might be the series ID if we're looking at series-level data
  1544. $actualItemId = $result['NowPlayingItemId'];
  1545. $this->setLoggerChannel('JellyStat')->info("Using NowPlayingItemId: {$actualItemId}");
  1546. } elseif (!empty($result['ParentId'])) {
  1547. // Last resort: ParentId (might be series, season, or library)
  1548. $actualItemId = $result['ParentId'];
  1549. $this->setLoggerChannel('JellyStat')->info("Using ParentId: {$actualItemId}");
  1550. }
  1551. if (!$actualItemId) {
  1552. $this->setLoggerChannel('JellyStat')->info("No suitable ID found for TV show: {$result['SeriesName']}");
  1553. }
  1554. } elseif ($contentType === 'music') {
  1555. // For music, use NowPlayingItemId (album/track)
  1556. $actualItemId = $result['NowPlayingItemId'] ?? null;
  1557. }
  1558. $itemStats[$key] = [
  1559. 'id' => $actualItemId ?? $itemId, // Use actual item ID if available, fallback to name-based ID
  1560. 'title' => $title,
  1561. 'type' => $contentType,
  1562. 'play_count' => 0,
  1563. 'total_duration' => 0,
  1564. 'year' => $year,
  1565. 'server_id' => $serverId,
  1566. 'poster_path' => $posterPath,
  1567. 'first_played' => $result['ActivityDateInserted'] ?? null,
  1568. 'last_played' => $result['ActivityDateInserted'] ?? null
  1569. ];
  1570. }
  1571. $itemStats[$key]['play_count']++;
  1572. $itemStats[$key]['total_duration'] += $result['PlaybackDuration'] ?? 0;
  1573. // Debug: Log each play count increment
  1574. if ($contentType === 'show') {
  1575. $this->setLoggerChannel('JellyStat')->info("Play count increment for {$title}: now {$itemStats[$key]['play_count']} (Episode: {$result['EpisodeName']}, User: {$result['UserName']}, Date: {$result['ActivityDateInserted']})");
  1576. }
  1577. // Update last played time
  1578. $currentTime = $result['ActivityDateInserted'] ?? null;
  1579. if ($currentTime && (!$itemStats[$key]['last_played'] || $currentTime > $itemStats[$key]['last_played'])) {
  1580. $itemStats[$key]['last_played'] = $currentTime;
  1581. }
  1582. // Update first played time
  1583. if ($currentTime && (!$itemStats[$key]['first_played'] || $currentTime < $itemStats[$key]['first_played'])) {
  1584. $itemStats[$key]['first_played'] = $currentTime;
  1585. }
  1586. }
  1587. }
  1588. // Separate by content type and sort by play count
  1589. foreach ($itemStats as $item) {
  1590. switch ($item['type']) {
  1591. case 'movie':
  1592. $processed['movies'][] = $item;
  1593. break;
  1594. case 'show':
  1595. $processed['shows'][] = $item;
  1596. break;
  1597. case 'music':
  1598. $processed['music'][] = $item;
  1599. break;
  1600. }
  1601. }
  1602. // Sort each category by play count (descending)
  1603. usort($processed['movies'], function($a, $b) {
  1604. return $b['play_count'] - $a['play_count'];
  1605. });
  1606. usort($processed['shows'], function($a, $b) {
  1607. return $b['play_count'] - $a['play_count'];
  1608. });
  1609. usort($processed['music'], function($a, $b) {
  1610. return $b['play_count'] - $a['play_count'];
  1611. });
  1612. return $processed;
  1613. }
  1614. /**
  1615. * Aggregate user statistics (plays and last activity) from JellyStat history
  1616. */
  1617. private function aggregateJellyStatUsers($historyResults)
  1618. {
  1619. $users = [];
  1620. foreach ($historyResults as $row) {
  1621. $name = $row['UserName'] ?? ($row['User'] ?? 'Unknown User');
  1622. if (!isset($users[$name])) {
  1623. $users[$name] = [
  1624. 'name' => $name,
  1625. 'play_count' => 0,
  1626. 'last_activity' => null,
  1627. ];
  1628. }
  1629. $users[$name]['play_count']++;
  1630. $activity = $row['ActivityDateInserted'] ?? ($row['Date'] ?? null);
  1631. if ($activity) {
  1632. if ($users[$name]['last_activity'] === null || $activity > $users[$name]['last_activity']) {
  1633. $users[$name]['last_activity'] = $activity;
  1634. }
  1635. }
  1636. }
  1637. // Sort by play_count desc, then by last_activity desc
  1638. usort($users, function($a, $b) {
  1639. if ($b['play_count'] === $a['play_count']) {
  1640. return strcmp($b['last_activity'] ?? '', $a['last_activity'] ?? '');
  1641. }
  1642. return $b['play_count'] <=> $a['play_count'];
  1643. });
  1644. // Return as a list
  1645. return array_values($users);
  1646. }
  1647. }