jellystat.php 85 KB

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