'JellyStat',
'enabled' => true,
'image' => 'plugins/images/homepage/jellystat.png',
'category' => 'Media Server',
'settingsArray' => __FUNCTION__
];
if ($infoOnly) {
return $homepageInformation;
}
$homepageSettings = [
'debug' => true,
'settings' => [
'Enable' => [
$this->settingsOption('enable', 'homepageJellyStatEnabled'),
$this->settingsOption('auth', 'homepageJellyStatAuth'),
],
'Display Mode' => [
$this->settingsOption('select', 'homepageJellyStatDisplayMode', ['label' => 'Display Mode', 'options' => [
['name' => 'Native Statistics View', 'value' => 'native'],
['name' => 'Embedded JellyStat Interface', 'value' => 'iframe']
]]),
],
'Connection' => [
$this->settingsOption('url', 'jellyStatURL', ['label' => 'JellyStat URL', 'help' => 'URL to your JellyStat instance']),
$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.']),
$this->settingsOption('token', 'jellyStatApikey', ['label' => 'JellyStat API Key', 'help' => 'API key for JellyStat (required for native mode)']),
$this->settingsOption('disable-cert-check', 'jellyStatDisableCertCheck'),
$this->settingsOption('use-custom-certificate', 'jellyStatUseCustomCertificate'),
],
'Native Mode Options' => [
$this->settingsOption('number', 'homepageJellyStatRefresh', ['label' => 'Auto-refresh Interval (minutes)', 'min' => 1, 'max' => 60]),
$this->settingsOption('number', 'homepageJellyStatDays', ['label' => 'Statistics Period (days)', 'min' => 1, 'max' => 365]),
$this->settingsOption('switch', 'homepageJellyStatShowLibraries', ['label' => 'Show Library Statistics']),
$this->settingsOption('switch', 'homepageJellyStatShowUsers', ['label' => 'Show User Statistics']),
$this->settingsOption('switch', 'homepageJellyStatShowMostWatched', ['label' => 'Show Most Watched Content']),
$this->settingsOption('switch', 'homepageJellyStatShowRecentActivity', ['label' => 'Show Recent Activity']),
$this->settingsOption('number', 'homepageJellyStatMaxItems', ['label' => 'Maximum Items to Display', 'min' => 5, 'max' => 50]),
],
'Most Watched Content' => [
$this->settingsOption('switch', 'homepageJellyStatShowMostWatchedMovies', ['label' => 'Show Most Watched Movies with Posters']),
$this->settingsOption('switch', 'homepageJellyStatShowMostWatchedShows', ['label' => 'Show Most Watched TV Shows with Posters']),
$this->settingsOption('switch', 'homepageJellyStatShowMostListenedMusic', ['label' => 'Show Most Listened Music with Cover Art']),
$this->settingsOption('number', 'homepageJellyStatMostWatchedCount', ['label' => 'Number of Most Watched Items to Display', 'min' => 1, 'max' => 50]),
],
'Iframe Mode Options' => [
$this->settingsOption('number', 'homepageJellyStatIframeHeight', ['label' => 'Iframe Height (pixels)', 'min' => 300, 'max' => 2000]),
$this->settingsOption('switch', 'homepageJellyStatIframeScrolling', ['label' => 'Allow Scrolling in Iframe']),
],
'Test Connection' => [
$this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
$this->settingsOption('test', 'jellystat'),
]
]
];
return array_merge($homepageInformation, $homepageSettings);
}
public function testConnectionJellyStat()
{
if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('test'), true)) {
return false;
}
$url = $this->config['jellyStatURL'] ?? '';
$token = $this->config['jellyStatApikey'] ?? '';
$disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
$customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
if (empty($url)) {
$this->setAPIResponse('error', 'JellyStat URL not configured', 500);
return false;
}
$displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
if ($displayMode === 'iframe') {
// For iframe mode, just test if the URL is reachable (use main URL for frontend)
try {
$options = $this->requestOptions($url, null, $disableCert, $customCert);
$response = Requests::get($this->qualifyURL($url), [], $options);
if ($response->success) {
$this->setAPIResponse('success', 'Successfully connected to JellyStat', 200);
return true;
} else {
$this->setAPIResponse('error', 'Failed to connect to JellyStat URL', 500);
return false;
}
} catch (Exception $e) {
$this->setAPIResponse('error', 'Connection test failed: ' . $e->getMessage(), 500);
return false;
}
} else {
// For native mode, test API connection
if (empty($token)) {
$this->setAPIResponse('error', 'JellyStat API key not configured for native mode', 500);
return false;
}
try {
// Use internal URL for server-side API calls if configured, otherwise use main URL
$internalUrl = $this->config['jellyStatInternalURL'] ?? '';
$apiUrl = !empty($internalUrl) ? $internalUrl : $url;
$options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
// Test JellyStat API - use query parameter authentication
$testUrl = $this->qualifyURL($apiUrl) . '/api/getLibraries?apiKey=' . urlencode($token);
$response = Requests::get($testUrl, [], $options);
if ($response->success) {
$data = json_decode($response->body, true);
if (isset($data) && is_array($data) && !isset($data['error'])) {
$this->setAPIResponse('success', 'Successfully connected to JellyStat API', 200);
return true;
}
// Check if there's an error message in the response
if (isset($data['error'])) {
$this->writeLog('error', 'JellyStat API test error: ' . $data['error']);
$this->setAPIResponse('error', 'JellyStat API error: ' . $data['error'], 500);
return false;
}
// Log the actual response for debugging
$this->writeLog('error', 'JellyStat API test: Valid HTTP response but invalid data format. Response: ' . substr($response->body, 0, 500));
}
// Log first endpoint failure details
$firstError = "HTTP {$response->status_code}: " . substr($response->body, 0, 200);
$this->writeLog('error', "JellyStat API test failed on /api/getLibraries: {$firstError}");
// If libraries test failed, the API key is likely invalid
$this->writeLog('error', 'JellyStat API key appears to be invalid or JellyStat API is not responding');
// Try basic connection test to see if JellyStat is even running
$response = Requests::get($this->qualifyURL($apiUrl), [], $options);
if ($response->success) {
$this->setAPIResponse('error', 'JellyStat is reachable but API key is invalid or API endpoints are not responding correctly.', 500);
} else {
$this->setAPIResponse('error', 'Cannot connect to JellyStat URL. Check URL and network connectivity.', 500);
}
return false;
} catch (Exception $e) {
$this->writeLog('error', 'JellyStat API test exception: ' . $e->getMessage());
$this->setAPIResponse('error', 'Connection test failed: ' . $e->getMessage(), 500);
return false;
}
}
}
public function jellystatHomepagePermissions($key = null)
{
$displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
// For iframe mode, only URL is required; for native mode, both URL and API key are required
$requiredFields = ['jellyStatURL'];
if ($displayMode === 'native') {
$requiredFields[] = 'jellyStatApikey';
}
$permissions = [
'test' => [
'enabled' => [
'homepageJellyStatEnabled',
],
'auth' => [
'homepageJellyStatAuth',
],
'not_empty' => $requiredFields
],
'main' => [
'enabled' => [
'homepageJellyStatEnabled'
],
'auth' => [
'homepageJellyStatAuth'
],
'not_empty' => $requiredFields
]
];
return $this->homepageCheckKeyPermissions($key, $permissions);
}
public function homepageOrderJellyStat()
{
if ($this->homepageItemPermissions($this->jellystatHomepagePermissions('main'))) {
$displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
if ($displayMode === 'iframe') {
return $this->renderJellyStatIframe();
} else {
return $this->renderJellyStatNative();
}
}
}
private function renderJellyStatIframe()
{
$url = $this->config['jellyStatURL'] ?? '';
$height = $this->config['homepageJellyStatIframeHeight'] ?? 800;
$scrolling = ($this->config['homepageJellyStatIframeScrolling'] ?? true) ? 'auto' : 'no';
return '
';
}
private function renderJellyStatNative()
{
$refreshInterval = ($this->config['homepageJellyStatRefresh'] ?? 5) * 60000; // Convert minutes to milliseconds
$days = $this->config['homepageJellyStatDays'] ?? 30;
$maxItems = $this->config['homepageJellyStatMaxItems'] ?? 10;
$showLibraries = ($this->config['homepageJellyStatShowLibraries'] ?? true) ? 'true' : 'false';
$showUsers = ($this->config['homepageJellyStatShowUsers'] ?? true) ? 'true' : 'false';
$showMostWatched = ($this->config['homepageJellyStatShowMostWatched'] ?? true) ? 'true' : 'false';
$showRecentActivity = ($this->config['homepageJellyStatShowRecentActivity'] ?? true) ? 'true' : 'false';
$showMostWatchedMovies = ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? true) ? 'true' : 'false';
$showMostWatchedShows = ($this->config['homepageJellyStatShowMostWatchedShows'] ?? true) ? 'true' : 'false';
$showMostListenedMusic = ($this->config['homepageJellyStatShowMostListenedMusic'] ?? true) ? 'true' : 'false';
$mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
$jellyStatUrl = htmlspecialchars($this->qualifyURL($this->config['jellyStatURL'] ?? ''), ENT_QUOTES, 'UTF-8');
return '
Loading JellyStat data...
';
}
/**
* Main function to get JellyStat data
*/
public function getJellyStatData($options = null)
{
if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('main'), true)) {
$this->setAPIResponse('error', 'User not approved to view this homepage item - check plugin configuration', 401);
return false;
}
try {
$url = $this->config['jellyStatURL'] ?? '';
$token = $this->config['jellyStatApikey'] ?? '';
$days = intval($this->config['homepageJellyStatDays'] ?? 30);
if (empty($url) || empty($token)) {
$this->setAPIResponse('error', 'JellyStat URL or API key not configured', 500);
return false;
}
$stats = $this->fetchJellyStatStats($url, $token, $days);
if (isset($stats['error']) && $stats['error']) {
$this->setAPIResponse('error', $stats['message'], 500);
return false;
}
$this->setAPIResponse('success', 'JellyStat data retrieved successfully', 200, $stats);
return true;
} catch (Exception $e) {
$this->setAPIResponse('error', 'Failed to retrieve JellyStat data: ' . $e->getMessage(), 500);
return false;
}
}
/**
* Fetch statistics from JellyStat API
*/
private function fetchJellyStatStats($url, $token, $days = 30)
{
$disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
$customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
// Use internal URL for server-side API calls if configured, otherwise use main URL
$internalUrl = $this->config['jellyStatInternalURL'] ?? '';
$apiUrl = !empty($internalUrl) ? $internalUrl : $url;
$options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
$baseUrl = $this->qualifyURL($apiUrl);
$stats = [
'period' => "{$days} days",
'libraries' => [],
'library_totals' => [],
'server_info' => [],
'most_watched_movies' => [],
'most_watched_shows' => [],
'most_listened_music' => []
];
$mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
try {
// Get Library Statistics - use query parameter authentication
$librariesUrl = $baseUrl . '/api/getLibraries?apiKey=' . urlencode($token);
$response = Requests::get($librariesUrl, [], $options);
if ($response->success) {
$data = json_decode($response->body, true);
if (is_array($data) && !isset($data['error'])) {
// Process individual libraries
$stats['libraries'] = array_map(function($lib) {
return [
'name' => $lib['Name'] ?? 'Unknown Library',
'type' => $this->getCollectionTypeLabel($lib['CollectionType'] ?? 'unknown'),
'item_count' => $lib['item_count'] ?? 0,
'season_count' => $lib['season_count'] ?? 0,
'episode_count' => $lib['episode_count'] ?? 0,
'total_play_time' => $lib['total_play_time'] ? $this->formatDuration($lib['total_play_time']) : '0 min',
'play_time_raw' => $lib['total_play_time'] ?? 0,
'collection_type' => $lib['CollectionType'] ?? 'unknown'
];
}, $data);
// Calculate totals across all libraries
$totalItems = array_sum(array_column($data, 'item_count'));
$totalSeasons = array_sum(array_column($data, 'season_count'));
$totalEpisodes = array_sum(array_column($data, 'episode_count'));
$totalPlayTime = array_sum(array_column($data, 'total_play_time'));
// Calculate library type breakdowns
$typeBreakdown = [];
foreach ($data as $lib) {
$type = $lib['CollectionType'] ?? 'unknown';
if (!isset($typeBreakdown[$type])) {
$typeBreakdown[$type] = [
'count' => 0,
'items' => 0,
'play_time' => 0,
'label' => $this->getCollectionTypeLabel($type)
];
}
$typeBreakdown[$type]['count']++;
$typeBreakdown[$type]['items'] += $lib['item_count'] ?? 0;
$typeBreakdown[$type]['play_time'] += $lib['total_play_time'] ?? 0;
}
$stats['library_totals'] = [
'total_libraries' => count($data),
'total_items' => $totalItems,
'total_seasons' => $totalSeasons,
'total_episodes' => $totalEpisodes,
'total_play_time' => $this->formatDuration($totalPlayTime),
'total_play_time_raw' => $totalPlayTime,
'type_breakdown' => $typeBreakdown
];
// Server information
$stats['server_info'] = [
'server_id' => $data[0]['ServerId'] ?? 'Unknown',
'last_updated' => date('c')
];
}
}
// Get History data and process to extract most watched content
$historyUrl = $baseUrl . '/api/getHistory?apiKey=' . urlencode($token) . '&size=500';
$response = Requests::get($historyUrl, [], $options);
if ($response->success) {
$historyData = json_decode($response->body, true);
if (is_array($historyData) && isset($historyData['results']) && is_array($historyData['results'])) {
// Process history to get most watched content
$processedData = $this->processJellyStatHistory($historyData['results']);
// Extract most watched items based on user settings
if ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? false) {
$stats['most_watched_movies'] = array_slice($processedData['movies'], 0, $mostWatchedCount);
}
if ($this->config['homepageJellyStatShowMostWatchedShows'] ?? false) {
$stats['most_watched_shows'] = array_slice($processedData['shows'], 0, $mostWatchedCount);
}
if ($this->config['homepageJellyStatShowMostListenedMusic'] ?? false) {
$stats['most_listened_music'] = array_slice($processedData['music'], 0, $mostWatchedCount);
}
}
}
} catch (Exception $e) {
return ['error' => true, 'message' => 'Failed to fetch JellyStat data: ' . $e->getMessage()];
}
return $stats;
}
/**
* Get human-readable label for collection type
*/
private function getCollectionTypeLabel($type)
{
$labels = [
'movies' => 'Movies',
'tvshows' => 'TV Shows',
'music' => 'Music',
'mixed' => 'Mixed Content',
'unknown' => 'Other'
];
return $labels[$type] ?? ucfirst($type);
}
/**
* Format bytes to human readable format
*/
private function formatBytes($size, $precision = 2)
{
if ($size == 0) return '0 B';
$base = log($size, 1024);
$suffixes = ['B', 'KB', 'MB', 'GB', 'TB'];
return round(pow(1024, $base - floor($base)), $precision) . ' ' . $suffixes[floor($base)];
}
/**
* Format duration for display
*/
private function formatDuration($ticks)
{
if ($ticks == 0) return 'Unknown';
// Convert ticks to seconds (ticks are in 100-nanosecond intervals)
$seconds = $ticks / 10000000;
if ($seconds < 3600) {
return gmdate('i:s', $seconds);
} else {
return gmdate('H:i:s', $seconds);
}
}
/**
* Process JellyStat history data to extract most watched content
*/
private function processJellyStatHistory($historyResults)
{
$processed = [
'movies' => [],
'shows' => [],
'music' => []
];
// Group items by ID and count plays
$itemStats = [];
foreach ($historyResults as $result) {
// Determine content type based on available data
$contentType = 'unknown';
$itemId = null;
$title = 'Unknown';
$year = null;
$serverId = $result['ServerId'] ?? null;
// Check if it's a TV show (has SeriesName)
if (!empty($result['SeriesName'])) {
$contentType = 'show';
$itemId = $result['SeriesName']; // Use series name as unique identifier
$title = $result['SeriesName'];
// Try to extract year from episode or series data
if (!empty($result['EpisodeName'])) {
// This is an episode, count toward the series
$episodeTitle = $result['EpisodeName'];
if (preg_match('/\b(19|20)\d{2}\b/', $episodeTitle, $matches)) {
$year = $matches[0];
}
}
}
// Check if it's a movie (has NowPlayingItemName but no SeriesName)
elseif (!empty($result['NowPlayingItemName']) && empty($result['SeriesName'])) {
// Determine if it's likely a movie or music based on duration or other hints
$itemName = $result['NowPlayingItemName'];
$duration = $result['PlaybackDuration'] ?? 0;
// If duration is very short (< 10 minutes) and no video streams, likely music
$hasVideo = false;
if (isset($result['MediaStreams']) && is_array($result['MediaStreams'])) {
foreach ($result['MediaStreams'] as $stream) {
if (($stream['Type'] ?? '') === 'Video') {
$hasVideo = true;
break;
}
}
}
if (!$hasVideo || $duration < 600) { // Less than 10 minutes and no video = likely music
$contentType = 'music';
$title = $itemName;
// For music, try to extract artist info
// Music tracks might have format like "Artist - Song" or just "Song"
} else {
$contentType = 'movie';
$title = $itemName;
// Try to extract year from movie title
if (preg_match('/\((19|20)\d{2}\)/', $title, $matches)) {
$year = $matches[1] . $matches[2];
$title = trim(str_replace($matches[0], '', $title));
}
}
$itemId = $result['NowPlayingItemId'] ?? $itemName;
}
if ($itemId && $contentType !== 'unknown') {
$key = $contentType . '_' . $itemId;
if (!isset($itemStats[$key])) {
// Extract poster/image information from JellyStat API response
$posterPath = null;
$actualItemId = null;
// Get the actual Jellyfin/Emby item ID for poster generation
// Note: JellyStat history API doesn't provide poster paths directly,
// so we'll use item IDs with JellyStat's image proxy API
if ($contentType === 'movie') {
// For movies, use the NowPlayingItemId
$actualItemId = $result['NowPlayingItemId'] ?? null;
} elseif ($contentType === 'show') {
// For TV shows, be more selective about ID selection to ensure we get series posters
// Priority: SeriesId (if exists) > specific logic for ParentId vs NowPlayingItemId
$actualItemId = null;
if (!empty($result['SeriesId'])) {
// SeriesId is the most reliable for series posters
$actualItemId = $result['SeriesId'];
} elseif (!empty($result['ShowId'])) {
// ShowId is also series-specific
$actualItemId = $result['ShowId'];
} elseif (!empty($result['ParentId']) && !empty($result['NowPlayingItemId'])) {
// When both ParentId and NowPlayingItemId exist:
// - If they're different, ParentId is likely the series/season
// - If they're the same, we might be at series level already
if ($result['ParentId'] !== $result['NowPlayingItemId']) {
// ParentId is likely series or season, prefer it over episode ID
$actualItemId = $result['ParentId'];
} else {
// They're the same, could be series-level already
$actualItemId = $result['NowPlayingItemId'];
}
} elseif (!empty($result['ParentId'])) {
// Only ParentId available, use it (might be series or season)
$actualItemId = $result['ParentId'];
} else {
// Fallback to NowPlayingItemId (likely episode, but better than nothing)
$actualItemId = $result['NowPlayingItemId'] ?? null;
}
} elseif ($contentType === 'music') {
// For music, use NowPlayingItemId (album/track)
$actualItemId = $result['NowPlayingItemId'] ?? null;
}
$itemStats[$key] = [
'id' => $actualItemId ?? $itemId, // Use actual item ID if available, fallback to name-based ID
'title' => $title,
'type' => $contentType,
'play_count' => 0,
'total_duration' => 0,
'year' => $year,
'server_id' => $serverId,
'poster_path' => $posterPath,
'first_played' => $result['ActivityDateInserted'] ?? null,
'last_played' => $result['ActivityDateInserted'] ?? null
];
}
$itemStats[$key]['play_count']++;
$itemStats[$key]['total_duration'] += $result['PlaybackDuration'] ?? 0;
// Update last played time
$currentTime = $result['ActivityDateInserted'] ?? null;
if ($currentTime && (!$itemStats[$key]['last_played'] || $currentTime > $itemStats[$key]['last_played'])) {
$itemStats[$key]['last_played'] = $currentTime;
}
// Update first played time
if ($currentTime && (!$itemStats[$key]['first_played'] || $currentTime < $itemStats[$key]['first_played'])) {
$itemStats[$key]['first_played'] = $currentTime;
}
}
}
// Separate by content type and sort by play count
foreach ($itemStats as $item) {
switch ($item['type']) {
case 'movie':
$processed['movies'][] = $item;
break;
case 'show':
$processed['shows'][] = $item;
break;
case 'music':
$processed['music'][] = $item;
break;
}
}
// Sort each category by play count (descending)
usort($processed['movies'], function($a, $b) {
return $b['play_count'] - $a['play_count'];
});
usort($processed['shows'], function($a, $b) {
return $b['play_count'] - $a['play_count'];
});
usort($processed['music'], function($a, $b) {
return $b['play_count'] - $a['play_count'];
});
return $processed;
}
}