Bladeren bron

Add embyLiveTVTracker homepage plugin with core integrations

- New homepage plugin for tracking Emby Live TV usage
- Core API modifications to support embyLiveTVTracker functionality
- Updated routes for connection testing and plugin management
- Homepage route integration for Live TV monitoring
- Configuration updates for plugin settings
- Plugin icon and interface components
mgomon 8 maanden geleden
bovenliggende
commit
65a8c72cb3

+ 16 - 8
api/classes/organizr.class.php

@@ -33,6 +33,7 @@ class Organizr
 	use DelugeHomepageItem;
 	use DonateHomepageItem;
 	use EmbyHomepageItem;
+	use EmbyLiveTVTrackerHomepageItem;
 	use HealthChecksHomepageItem;
 	use HTMLHomepageItem;
 	use ICalHomepageItem;
@@ -4611,14 +4612,21 @@ class Organizr
 						$class .= ' faded';
 					}
 					break;
-				case 'homepageOrderembynowplaying':
-				case 'homepageOrderembyrecent':
-					$class = 'bg-emby';
-					$image = 'plugins/images/tabs/emby.png';
-					if (!$this->config['homepageEmbyEnabled']) {
-						$class .= ' faded';
-					}
-					break;
+			case 'homepageOrderembynowplaying':
+			case 'homepageOrderembyrecent':
+				$class = 'bg-emby';
+				$image = 'plugins/images/tabs/emby.png';
+				if (!$this->config['homepageEmbyEnabled']) {
+					$class .= ' faded';
+				}
+				break;
+			case 'homepageOrderEmbyLiveTVTracker':
+				$class = 'bg-emby';
+				$image = 'plugins/images/homepage/embyLiveTVTracker.png';
+				if (!$this->config['homepageEmbyLiveTVTrackerEnabled']) {
+					$class .= ' faded';
+				}
+				break;
 				case 'homepageOrderjellyfinnowplaying':
 				case 'homepageOrderjellyfinrecent':
 					$class = 'bg-jellyfin';

+ 12 - 0
api/config/default.php

@@ -377,6 +377,18 @@ return [
 	'homepageOrderAdguard' => '42',
 	'homepageOrderProwlarr' => '43',
 	'homepageOrderUptimeKuma' => '44',
+	'homepageOrderEmbyLiveTVTracker' => '45',
+	'homepageEmbyLiveTVTrackerEnabled' => false,
+	'homepageEmbyLiveTVTrackerAuth' => '1',
+	'homepageEmbyLiveTVTrackerRefresh' => '5',
+	'homepageEmbyLiveTVTrackerDaysShown' => '7',
+	'homepageEmbyLiveTVTrackerCompactView' => false,
+	'homepageEmbyLiveTVTrackerShowDuration' => true,
+	'homepageEmbyLiveTVTrackerShowSeriesInfo' => true,
+	'homepageEmbyLiveTVTrackerShowUserInfo' => false,
+	'homepageEmbyLiveTVTrackerMaxItems' => '10',
+	'homepageEmbyLiveTVTrackerShowCompleted' => true,
+	'homepageEmbyLiveTVTrackerMaxCompletedItems' => '5',
 	'homepageShowStreamNames' => false,
 	'homepageShowStreamNamesAuth' => '1',
 	'homepageShowStreamNamesWithoutIp' => false,

+ 4 - 0
api/functions.php

@@ -10,6 +10,10 @@ foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'functions' . DIRECTORY_SEPARATOR
 foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'homepage' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
 	require_once $filename;
 }
+// Include EmbyLiveTVTracker trait before class loading
+if (file_exists(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'embyLiveTVTracker' . DIRECTORY_SEPARATOR . 'api.php')) {
+	require_once __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'embyLiveTVTracker' . DIRECTORY_SEPARATOR . 'api.php';
+}
 foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
 	require_once $filename;
 }

+ 801 - 0
api/homepage/embyLiveTVTracker.php

@@ -0,0 +1,801 @@
+<?php
+
+trait EmbyLiveTVTrackerHomepageItem
+{
+    public function embyLiveTVTrackerSettingsArray($infoOnly = false)
+    {
+        $homepageInformation = [
+            'name' => 'EmbyLiveTVTracker',
+            'enabled' => strpos('personal', $this->config['license']) !== false,
+            'image' => 'plugins/images/homepage/embyLiveTVTracker.png',
+            'category' => 'Media Server',
+            'settingsArray' => __FUNCTION__
+        ];
+        if ($infoOnly) {
+            return $homepageInformation;
+        }
+        $homepageSettings = [
+            'debug' => true,
+            'settings' => [
+                'Enable' => [
+                    $this->settingsOption('enable', 'homepageEmbyLiveTVTrackerEnabled'),
+                    $this->settingsOption('auth', 'homepageEmbyLiveTVTrackerAuth'),
+                ],
+                'Connection' => [
+                    $this->settingsOption('url', 'embyURL'),
+                    $this->settingsOption('token', 'embyToken'),
+                    $this->settingsOption('disable-cert-check', 'embyDisableCertCheck'),
+                    $this->settingsOption('use-custom-certificate', 'embyUseCustomCertificate'),
+                ],
+                'Display Options' => [
+                    $this->settingsOption('number', 'homepageEmbyLiveTVTrackerRefresh', ['label' => 'Auto-refresh Interval (minutes)', 'min' => 1, 'max' => 60]),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerCompactView', ['label' => 'Use Compact View']),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerShowDuration', ['label' => 'Show Recording Duration']),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerShowSeriesInfo', ['label' => 'Show Series Information']),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerShowUserInfo', ['label' => 'Show User Information']),
+                    $this->settingsOption('number', 'homepageEmbyLiveTVTrackerMaxItems', ['label' => 'Maximum Scheduled Items', 'min' => 5, 'max' => 50]),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerShowCompleted', ['label' => 'Show Completed Recordings']),
+                    $this->settingsOption('number', 'homepageEmbyLiveTVTrackerDaysShown', ['label' => 'Days of Completed Recordings', 'min' => 1, 'max' => 30]),
+                    $this->settingsOption('number', 'homepageEmbyLiveTVTrackerMaxCompletedItems', ['label' => 'Maximum Completed Items', 'min' => 5, 'max' => 50]),
+                    $this->settingsOption('switch', 'homepageEmbyLiveTVTrackerDebug', ['label' => 'Enable Debug Logging']),
+                ],
+                'Test Connection' => [
+                    $this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
+                    $this->settingsOption('test', 'embyLiveTVTracker'),
+                ]
+            ]
+        ];
+        return array_merge($homepageInformation, $homepageSettings);
+    }
+
+    public function testConnectionEmbyLiveTVTracker()
+    {
+        if (!$this->homepageItemPermissions($this->embyLiveTVTrackerHomepagePermissions('test'), true)) {
+            return false;
+        }
+        $url = $this->qualifyURL($this->config['embyURL']);
+        $url = $url . "/emby/System/Info?api_key=" . $this->config['embyToken'];
+        $options = $this->requestOptions($url, null, $this->config['embyDisableCertCheck'], $this->config['embyUseCustomCertificate']);
+        try {
+            $response = Requests::get($url, [], $options);
+            if ($response->success) {
+                $info = json_decode($response->body, true);
+                if (isset($info['ServerName'])) {
+                    // Test LiveTV functionality
+                    $liveTvUrl = $this->qualifyURL($this->config['embyURL']) . '/emby/LiveTv/Info?api_key=' . $this->config['embyToken'];
+                    try {
+                        $liveTvResponse = Requests::get($liveTvUrl, [], $options);
+                        $liveTvInfo = json_decode($liveTvResponse->body, true);
+                        $hasLiveTV = isset($liveTvInfo['Services']) && count($liveTvInfo['Services']) > 0;
+                        $message = 'Successfully connected to ' . $info['ServerName'];
+                        if ($hasLiveTV) {
+                            $message .= ' with LiveTV support enabled';
+                        } else {
+                            $message .= ' (Warning: LiveTV may not be configured)';
+                        }
+                        $this->setAPIResponse('success', $message, 200);
+                    } catch (Exception $e) {
+                        $this->setAPIResponse('success', 'Connected to ' . $info['ServerName'] . ' but LiveTV status unknown', 200);
+                    }
+                } else {
+                    $this->setAPIResponse('error', 'Invalid response from Emby server', 500);
+                }
+                return true;
+            } else {
+                $this->setAPIResponse('error', 'Emby Connection Error', 500);
+                return false;
+            }
+        } catch (Requests_Exception $e) {
+            $this->setResponse(500, $e->getMessage());
+            return false;
+        }
+    }
+
+    public function embyLiveTVTrackerHomepagePermissions($key = null)
+    {
+        $permissions = [
+            'test' => [
+                'enabled' => [
+                    'homepageEmbyLiveTVTrackerEnabled',
+                ],
+                'auth' => [
+                    'homepageEmbyLiveTVTrackerAuth',
+                ],
+                'not_empty' => [
+                    'embyURL',
+                    'embyToken'
+                ]
+            ],
+            'main' => [
+                'enabled' => [
+                    'homepageEmbyLiveTVTrackerEnabled'
+                ],
+                'auth' => [
+                    'homepageEmbyLiveTVTrackerAuth'
+                ],
+                'not_empty' => [
+                    'embyURL',
+                    'embyToken'
+                ]
+            ]
+        ];
+        return $this->homepageCheckKeyPermissions($key, $permissions);
+    }
+
+    public function homepageOrderEmbyLiveTVTracker()
+    {
+        if ($this->homepageItemPermissions($this->embyLiveTVTrackerHomepagePermissions('main'))) {
+            $refreshInterval = ($this->config['homepageEmbyLiveTVTrackerRefresh'] ?? 5) * 60000; // Convert minutes to milliseconds
+            $compactView = ($this->config['homepageEmbyLiveTVTrackerCompactView'] ?? false) ? 'true' : 'false';
+            $showDuration = ($this->config['homepageEmbyLiveTVTrackerShowDuration'] ?? true) ? 'true' : 'false';
+            $showSeriesInfo = ($this->config['homepageEmbyLiveTVTrackerShowSeriesInfo'] ?? true) ? 'true' : 'false';
+            $showUserInfo = ($this->config['homepageEmbyLiveTVTrackerShowUserInfo'] ?? false) ? 'true' : 'false';
+            $maxItems = $this->config['homepageEmbyLiveTVTrackerMaxItems'] ?? 10;
+            $showCompleted = ($this->config['homepageEmbyLiveTVTrackerShowCompleted'] ?? true) ? 'true' : 'false';
+            $daysShown = $this->config['homepageEmbyLiveTVTrackerDaysShown'] ?? 7;
+            $maxCompletedItems = $this->config['homepageEmbyLiveTVTrackerMaxCompletedItems'] ?? 5;
+
+            $panelClass = ($compactView === 'true') ? 'panel-compact' : '';
+            $statsClass = ($compactView === 'true') ? 'col-sm-6' : 'col-sm-3';
+
+            return '
+            <div id="' . __FUNCTION__ . '">
+                <div class="white-box ' . $panelClass . '">
+                    <div class="white-box-header">
+                        <i class="fa fa-tv"></i> Emby LiveTV Tracker
+                        <span class="pull-right">
+                            <small id="embylivetv-last-update" class="text-muted"></small>
+                            <button class="btn btn-xs btn-primary" onclick="refreshEmbyLiveTVData()" title="Refresh Data">
+                                <i class="fa fa-refresh" id="embylivetv-refresh-icon"></i>
+                            </button>
+                        </span>
+                    </div>
+                    <div class="white-box-content">
+                        <div class="row" id="embylivetv-stats">
+                            <div class="' . $statsClass . '">
+                                <div class="text-center">
+                                    <h3 id="embylivetv-active-timers" class="text-success">-</h3>
+                                    <small>Active Timers</small>
+                                </div>
+                            </div>
+                            <div class="' . $statsClass . '">
+                                <div class="text-center">
+                                    <h3 id="embylivetv-series-timers" class="text-info">-</h3>
+                                    <small>Series Timers</small>
+                                </div>
+                            </div>
+                            ' . (($compactView === 'false') ? '
+                            <div class="' . $statsClass . '">
+                                <div class="text-center">
+                                    <h3 id="embylivetv-today-recordings" class="text-warning">-</h3>
+                                    <small>Today\'s Recordings</small>
+                                </div>
+                            </div>
+                            <div class="' . $statsClass . '">
+                                <div class="text-center">
+                                    <h3 id="embylivetv-total-recordings" class="text-primary">-</h3>
+                                    <small>Total Recordings</small>
+                                </div>
+                            </div>
+                            ' : '') . '
+                        </div>
+
+                        <!-- Scheduled Recordings Table -->
+                        <div class="row" style="margin-top: 20px;">
+                            <div class="col-lg-12">
+                                <h4>
+                                    Scheduled Recordings
+                                    <small class="text-muted">Upcoming and active timers</small>
+                                </h4>
+                                <div class="table-responsive">
+                                    <table class="table table-hover table-striped table-condensed">
+                                        <thead>
+                                            <tr>
+                                                <th width="120">Date</th>
+                                                <th>Series</th>
+                                                ' . (($showSeriesInfo === 'true') ? '<th>Episode Title</th>' : '') . '
+                                                <th>Channel</th>
+                                                ' . (($showUserInfo === 'true') ? '<th>User</th>' : '') . '
+                                                ' . (($showDuration === 'true') ? '<th width="80">Duration</th>' : '') . '
+                                                <th width="70">Status</th>
+                                            </tr>
+                                        </thead>
+                                        <tbody id="embylivetv-scheduled-table">
+                                            <tr>
+                                                <td colspan="' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '" class="text-center">
+                                                    <i class="fa fa-spinner fa-spin"></i> Loading...
+                                                </td>
+                                            </tr>
+                                        </tbody>
+                                    </table>
+                                </div>
+                            </div>
+                        </div>
+                        
+                        <!-- Completed Recordings Table (only if enabled) -->
+                        ' . (($showCompleted === 'true') ? '
+                        <div class="row" style="margin-top: 20px;">
+                            <div class="col-lg-12">
+                                <h4>
+                                    Completed Recordings
+                                    <small class="text-muted">Recent recordings</small>
+                                </h4>
+                                <div class="table-responsive">
+                                    <table class="table table-hover table-striped table-condensed">
+                                        <thead>
+                                            <tr>
+                                                <th width="120">Date</th>
+                                                <th>Series</th>
+                                                ' . (($showSeriesInfo === 'true') ? '<th>Series</th>' : '') . '
+                                                ' . (($showDuration === 'true') ? '<th width="80">Duration</th>' : '') . '
+                                                <th width="70">Status</th>
+                                            </tr>
+                                        </thead>
+                                        <tbody id="embylivetv-completed-table">
+                                            <tr>
+                                                <td colspan="' . (3 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '" class="text-center">
+                                                    <i class="fa fa-spinner fa-spin"></i> Loading...
+                                                </td>
+                                            </tr>
+                                        </tbody>
+                                    </table>
+                                </div>
+                                </div>
+                        </div>
+                        ' : '') . '
+                    </div>
+                </div>
+            </div>
+
+            <style>
+            .panel-compact .white-box-content { padding: 10px; }
+            .panel-compact h3 { margin: 5px 0; font-size: 1.8em; }
+            .panel-compact small { font-size: 0.85em; }
+            #' . __FUNCTION__ . ' .table-condensed td { padding: 4px 8px; font-size: 0.9em; }
+            #' . __FUNCTION__ . ' .status-success { color: #5cb85c; }
+            #' . __FUNCTION__ . ' .status-recording { color: #d9534f; }
+            #' . __FUNCTION__ . ' .status-scheduled { color: #f0ad4e; }
+            </style>
+
+            <script>
+            var embyLiveTVRefreshTimer;
+            var embyLiveTVLastRefresh = 0;
+
+            function refreshEmbyLiveTVData() {
+                var refreshIcon = $("#embylivetv-refresh-icon");
+                refreshIcon.addClass("fa-spin");
+
+                // Show loading state
+                $("#embylivetv-stats h3").text("-");
+                $("#embylivetv-scheduled-table").html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center\"><i class=\"fa fa-spinner fa-spin\"></i> Loading...</td></tr>");
+                ' . (($showCompleted === 'true') ? '$("#embylivetv-completed-table").html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center\"><i class=\"fa fa-spinner fa-spin\"></i> Loading...</td></tr>");' : '') . ';
+
+                // Load stats and activity
+                homepageEmbyLiveTVTrackerStats()
+                .always(function() {
+                    refreshIcon.removeClass("fa-spin");
+                    embyLiveTVLastRefresh = Date.now();
+                    updateEmbyLiveTVLastRefreshTime();
+                });
+            }
+
+            function updateEmbyLiveTVLastRefreshTime() {
+                if (embyLiveTVLastRefresh > 0) {
+                    var ago = Math.floor((Date.now() - embyLiveTVLastRefresh) / 1000);
+                    var timeText = ago < 60 ? ago + "s ago" : Math.floor(ago / 60) + "m ago";
+                    $("#embylivetv-last-update").text("Updated " + timeText);
+                }
+            }
+
+            function homepageEmbyLiveTVTrackerStats() {
+                return organizrAPI2("GET", "api/v2/homepage/embyLiveTVTracker/stats")
+                .done(function(data) {
+                    console.log("Stats response received:", data);
+                    if (data && data.response && data.response.result === "success" && data.response.data) {
+                        console.log("Stats data is valid, loading activity...");
+                        $("#embylivetv-active-timers").text(data.response.data.activeTimers || "0");
+                        $("#embylivetv-series-timers").text(data.response.data.seriesTimers || "0");
+                        ' . (($compactView === 'false') ? '
+                        $("#embylivetv-today-recordings").text(data.response.data.todaysRecordings || "0");
+                        $("#embylivetv-total-recordings").text(data.response.data.totalRecordings || "0");
+                        ' : '') . '
+                        
+                        // Load activity
+                        homepageEmbyLiveTVTrackerActivity();
+                    } else {
+                        console.error("Stats response structure issue:", {
+                            hasData: !!data,
+                            hasResponse: !!(data && data.response),
+                            result: data && data.response && data.response.result,
+                            hasResponseData: !!(data && data.response && data.response.data)
+                        });
+                        console.error("Failed to load Emby LiveTV stats:", data.response ? data.response.message : "Unknown error");
+                        $("#embylivetv-stats h3").text("?").attr("title", "Error loading data");
+                    }
+                })
+                .fail(function(xhr, status, error) {
+                    console.error("Error loading Emby LiveTV stats:", error);
+                    $("#embylivetv-stats h3").text("!").attr("title", "Connection failed");
+                });
+            }
+
+            function homepageEmbyLiveTVTrackerActivity() {
+                console.log("Activity function called - making API request...");
+                return organizrAPI2("GET", "api/v2/homepage/embyLiveTVTracker/activity?days=' . ($daysShown ?: 7) . '\u0026limit=' . ($maxItems ?: 10) . '")
+                .done(function(data) {
+                    console.log("Activity response received:", data);
+                    console.log("Response structure check:", {
+                        hasData: !!data,
+                        hasResponse: !!(data && data.response),
+                        result: data && data.response && data.response.result,
+                        hasResponseData: !!(data && data.response && data.response.data),
+                        hasActivities: !!(data && data.response && data.response.data && data.response.data.activities),
+                        activitiesLength: data && data.response && data.response.data && data.response.data.activities ? data.response.data.activities.length : 0
+                    });
+                    if (data && data.response && data.response.result === "success" && data.response.data) {
+                        var scheduledRecordings = data.response.data.scheduledRecordings || [];
+                        var completedRecordings = data.response.data.completedRecordings || [];
+                        
+                        console.log("Scheduled recordings:", scheduledRecordings.length);
+                        console.log("Completed recordings:", completedRecordings.length);
+                        
+                        // Apply limits to each category separately to ensure we show both types
+                        var maxScheduled = Math.floor(' . $maxItems . ' * 0.7); // 70% for scheduled
+                        var maxCompleted = ' . $maxItems . ' - maxScheduled; // 30% for completed
+                        
+                        // If we have fewer scheduled than the 70% allocation, give more space to completed
+                        if (scheduledRecordings.length < maxScheduled) {
+                            maxCompleted = Math.min(completedRecordings.length, ' . $maxItems . ' - scheduledRecordings.length);
+                        }
+                        // If we have fewer completed than the 30% allocation, give more space to scheduled
+                        if (completedRecordings.length < maxCompleted) {
+                            maxScheduled = Math.min(scheduledRecordings.length, ' . $maxItems . ' - completedRecordings.length);
+                        }
+                        
+                        var scheduledActivities = scheduledRecordings.slice(0, maxScheduled);
+                        var completedActivities = completedRecordings.slice(0, maxCompleted);
+                        
+                        console.log("Split - Scheduled activities:", scheduledActivities.length);
+                        console.log("Split - Completed activities:", completedActivities.length);
+
+                        // Helper function to format scheduled activity rows
+                        function formatScheduledRow(activity) {
+                            var date = new Date(activity.date);
+                            var formattedDate = date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute:"2-digit"});
+                            var status = activity.status || "Scheduled";
+                            var statusClass = status.toLowerCase() === "completed" ? "status-success" :
+                                            status.toLowerCase() === "recording" ? "status-recording" : "status-scheduled";
+
+                            return "<tr>" +
+                                "<td><small>" + formattedDate + "</small></td>" +
+                                "<td>" + (activity.seriesName || activity.name || "-") + "</td>" +
+                                ' . (($showSeriesInfo === 'true') ? '"<td><small>" + (activity.episodeTitle || activity.name || "-") + "</small></td>" +' : '') . '
+                                "<td><small>" + (activity.channelName || "-") + "</small></td>" +
+                                ' . (($showUserInfo === 'true') ? '"<td><small>" + (activity.userName || "-") + "</small></td>" +' : '') . '
+                                ' . (($showDuration === 'true') ? '"<td><small>" + (activity.duration || "-") + "</small></td>" +' : '') . '
+                                "<td><span class=\"" + statusClass + "\"><i class=\"fa fa-circle\"></i></span></td>" +
+                                "</tr>";
+                        }
+                        
+                        // Helper function to format completed activity rows (no channel or user columns)
+                        function formatCompletedRow(activity) {
+                            var date = new Date(activity.date);
+                            var formattedDate = date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute:"2-digit"});
+                            var status = activity.status || "Completed";
+                            var statusClass = "status-success";
+
+                            return "<tr>" +
+                                "<td><small>" + formattedDate + "</small></td>" +
+                                "<td>" + (activity.seriesName || activity.name || "-") + "</td>" +
+                                ' . (($showSeriesInfo === 'true') ? '"<td><small>" + (activity.seriesName || "-") + "</small></td>" +' : '') . '
+                                ' . (($showDuration === 'true') ? '"<td><small>" + (activity.duration || "-") + "</small></td>" +' : '') . '
+                                "<td><span class=\"" + statusClass + "\"><i class=\"fa fa-circle\"></i></span></td>" +
+                                "</tr>";
+                        }
+                        
+                        // Populate scheduled recordings table
+                        var scheduledTable = $("#embylivetv-scheduled-table");
+                        if (scheduledActivities.length === 0) {
+                            scheduledTable.html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0)) . '\" class=\"text-center text-muted\">No scheduled recordings</td></tr>");
+                        } else {
+                            var scheduledRows = scheduledActivities.map(formatScheduledRow).join("");
+                            scheduledTable.html(scheduledRows);
+                        }
+                        
+                        // Populate completed recordings table (only if enabled and table exists)
+                        ' . (($showCompleted === 'true') ? '
+                        var completedTable = $("#embylivetv-completed-table");
+                        if (completedActivities.length === 0) {
+                            completedTable.html("<tr><td colspan=\"' . (3 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center text-muted\">No completed recordings</td></tr>");
+                        } else {
+                            var completedRows = completedActivities.map(formatCompletedRow).join("");
+                            completedTable.html(completedRows);
+                        }
+                        ' : '') . '
+                    } else {
+                        $("#embylivetv-scheduled-table").html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0)) . '\" class=\"text-center text-muted\">No activity data available</td></tr>");
+                        ' . (($showCompleted === 'true') ? '$("#embylivetv-completed-table").html("<tr><td colspan=\"' . (3 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center text-muted\">No activity data available</td></tr>");' : '') . '
+                    }
+                })
+                .fail(function(xhr, status, error) {
+                    console.error("Error loading Emby LiveTV activity:", error);
+                    $("#embylivetv-scheduled-table").html("<tr><td colspan=\"' . (4 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showUserInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center text-danger\">Error loading activity data</td></tr>");
+                    ' . (($showCompleted === 'true') ? '$("#embylivetv-completed-table").html("<tr><td colspan=\"' . (3 + ($showSeriesInfo === 'true' ? 1 : 0) + ($showDuration === 'true' ? 1 : 0)) . '\" class=\"text-center text-danger\">Error loading activity data</td></tr>");' : '') . '
+                });
+            }
+
+            // Auto-refresh setup
+            var refreshInterval = ' . $refreshInterval . ';
+            if (refreshInterval > 0) {
+                embyLiveTVRefreshTimer = setInterval(function() {
+                    refreshEmbyLiveTVData();
+                }, refreshInterval);
+            }
+
+            // Update time display every 30 seconds
+            setInterval(updateEmbyLiveTVLastRefreshTime, 30000);
+
+            // Initial load
+            $(document).ready(function() {
+                refreshEmbyLiveTVData();
+            });
+
+            // Cleanup timer when page unloads
+            $(window).on("beforeunload", function() {
+                if (embyLiveTVRefreshTimer) {
+                    clearInterval(embyLiveTVRefreshTimer);
+                }
+            });
+            </script>
+            ';
+        }
+    }
+
+    public function getHomepageEmbyLiveTVStats()
+    {
+        if (!$this->homepageItemPermissions($this->embyLiveTVTrackerHomepagePermissions('main'), true)) {
+            return false;
+        }
+        
+        if (!$this->config['embyURL'] || !$this->config['embyToken']) {
+            $this->setAPIResponse('error', 'Emby URL or Token not configured', 500);
+            return false;
+        }
+        
+        try {
+            $options = $this->requestOptions($this->config['embyURL'], null, $this->config['embyDisableCertCheck'], $this->config['embyUseCustomCertificate']);
+            $baseUrl = $this->qualifyURL($this->config['embyURL']);
+            
+            $stats = [
+                'activeTimers' => 0,
+                'seriesTimers' => 0,
+                'todaysRecordings' => 0,
+                'totalRecordings' => 0,
+                'recentRecordings' => []
+            ];
+            
+            // Get active timers
+            $timersUrl = $baseUrl . '/emby/LiveTv/Timers?api_key=' . $this->config['embyToken'];
+            try {
+                $timersResponse = Requests::get($timersUrl, [], $options);
+                if ($timersResponse->success) {
+                    $timers = json_decode($timersResponse->body, true);
+                    $stats['activeTimers'] = count($timers['Items'] ?? []);
+                }
+            } catch (Exception $e) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get timers: ' . $e->getMessage());
+            }
+            
+            // Get series timers
+            $seriesTimersUrl = $baseUrl . '/emby/LiveTv/SeriesTimers?api_key=' . $this->config['embyToken'];
+            try {
+                $seriesTimersResponse = Requests::get($seriesTimersUrl, [], $options);
+                if ($seriesTimersResponse->success) {
+                    $seriesTimers = json_decode($seriesTimersResponse->body, true);
+                    $stats['seriesTimers'] = count($seriesTimers['Items'] ?? []);
+                }
+            } catch (Exception $e) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get series timers: ' . $e->getMessage());
+            }
+            
+            // Get recordings from the last 90 days
+            $recordingsUrl = $baseUrl . '/emby/LiveTv/Recordings?api_key=' . $this->config['embyToken'] . '&StartIndex=0&Limit=50&Fields=Overview,DateCreated&SortBy=DateCreated&SortOrder=Descending';
+            try {
+                $recordingsResponse = Requests::get($recordingsUrl, [], $options);
+                if ($recordingsResponse->success) {
+                    $recordings = json_decode($recordingsResponse->body, true);
+                    $allRecordings = $recordings['Items'] ?? [];
+                    
+                    // Count today's recordings
+                    $today = date('Y-m-d');
+                    $todaysCount = 0;
+                    $recentRecordings = [];
+                    
+                    foreach ($allRecordings as $recording) {
+                        if (isset($recording['DateCreated'])) {
+                            $recordDate = date('Y-m-d', strtotime($recording['DateCreated']));
+                            if ($recordDate === $today) {
+                                $todaysCount++;
+                            }
+                            
+                            // Add to recent recordings list
+                            $recentRecordings[] = [
+                                'date' => $recordDate,
+                                'program' => $recording['Name'] ?? 'Unknown',
+                                'series' => $recording['SeriesName'] ?? '',
+                                'channel' => $recording['ChannelName'] ?? 'Unknown Channel',
+                                'status' => 'Completed'
+                            ];
+                        }
+                    }
+                    
+                    $stats['todaysRecordings'] = $todaysCount;
+                    $stats['totalRecordings'] = $recordings['TotalRecordCount'] ?? count($allRecordings);
+                    $stats['recentRecordings'] = array_slice($recentRecordings, 0, 10); // Limit to 10 recent recordings
+                }
+            } catch (Exception $e) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get recordings: ' . $e->getMessage());
+            }
+            
+            $this->setAPIResponse('success', 'LiveTV stats retrieved successfully', 200, $stats);
+            return true;
+            
+        } catch (Exception $e) {
+            $this->setAPIResponse('error', 'Failed to retrieve LiveTV stats: ' . $e->getMessage(), 500);
+            return false;
+        }
+    }
+
+    public function getHomepageEmbyLiveTVActivity()
+    {
+        $debugEnabled = $this->config['homepageEmbyLiveTVTrackerDebug'] ?? false;
+        
+        if (!$this->homepageItemPermissions($this->embyLiveTVTrackerHomepagePermissions('main'), true)) {
+            if ($debugEnabled) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Permission denied for user');
+            }
+            $this->setAPIResponse('error', 'Permission denied', 403);
+            return false;
+        }
+        
+        if (!$this->config['embyURL'] || !$this->config['embyToken']) {
+            $this->setAPIResponse('error', 'Emby URL or Token not configured', 500);
+            return false;
+        }
+        
+        try {
+            if ($debugEnabled) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->info('Activity method called - starting execution');
+            }
+            $options = $this->requestOptions($this->config['embyURL'], null, $this->config['embyDisableCertCheck'], $this->config['embyUseCustomCertificate']);
+            $baseUrl = $this->qualifyURL($this->config['embyURL']);
+            if ($debugEnabled) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->info('Base URL configured: ' . $baseUrl);
+            }
+            
+            $scheduledRecordings = [];
+            $completedRecordings = [];
+            $maxItems = intval($this->config['homepageEmbyLiveTVTrackerMaxItems'] ?? 10);
+            $showCompleted = $this->config['homepageEmbyLiveTVTrackerShowCompleted'] ?? true;
+            $maxCompletedItems = intval($this->config['homepageEmbyLiveTVTrackerMaxCompletedItems'] ?? 5);
+            
+            // Get user info if user info is enabled
+            $userMap = [];
+            $showUserInfo = $this->config['homepageEmbyLiveTVTrackerShowUserInfo'] ?? false;
+            if ($showUserInfo) {
+                $usersUrl = $baseUrl . '/emby/Users?api_key=' . $this->config['embyToken'];
+                try {
+                    $usersResponse = Requests::get($usersUrl, [], $options);
+                    if ($usersResponse->success) {
+                        $users = json_decode($usersResponse->body, true);
+                        foreach ($users as $user) {
+                            $userMap[$user['Id']] = $user['Name'];
+                        }
+                        if ($debugEnabled) {
+                            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Retrieved ' . count($userMap) . ' users for mapping');
+                        }
+                    }
+                } catch (Exception $e) {
+                    $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get users: ' . $e->getMessage());
+                }
+            }
+            
+            // Get scheduled recordings (active timers)
+            $timersUrl = $baseUrl . '/emby/LiveTv/Timers?api_key=' . $this->config['embyToken'] . '&Fields=ChannelName,ChannelId,SeriesName,ProgramInfo,StartDate,EndDate,UserId';
+            if ($debugEnabled) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->info('Fetching timers from URL: ' . $timersUrl);
+            }
+            try {
+                $timersResponse = Requests::get($timersUrl, [], $options);
+                if ($debugEnabled) {
+                    $this->setLoggerChannel('EmbyLiveTVTracker')->info('Timers API response status: ' . ($timersResponse->success ? 'success' : 'failed'));
+                    $this->setLoggerChannel('EmbyLiveTVTracker')->info('Timers API response code: ' . $timersResponse->status_code);
+                }
+                if ($timersResponse->success) {
+                    $timers = json_decode($timersResponse->body, true);
+                    $allTimers = $timers['Items'] ?? [];
+                    if ($debugEnabled) {
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Retrieved ' . count($allTimers) . ' timers from Emby API');
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Timers API raw response length: ' . strlen($timersResponse->body));
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('First timer sample: ' . json_encode(array_slice($allTimers, 0, 1)));
+                    }
+                    
+                    // Sort timers by start date
+                    usort($allTimers, function($a, $b) {
+                        $aDate = $a['StartDate'] ?? '';
+                        $bDate = $b['StartDate'] ?? '';
+                        return strcmp($aDate, $bDate);
+                    });
+
+                    $timersToProcess = array_slice($allTimers, 0, intval($maxItems));
+                    if ($debugEnabled) {
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Processing ' . count($timersToProcess) . ' timers (maxItems: ' . $maxItems . ')');
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('All timers count before slice: ' . count($allTimers));
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('MaxItems value: ' . $maxItems . ' (type: ' . gettype($maxItems) . ')');
+                    }
+                    
+                    foreach ($timersToProcess as $index => $timer) {
+                        if ($debugEnabled) {
+                            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Processing timer ' . ($index + 1) . ': ' . json_encode([
+                                'Name' => $timer['Name'] ?? 'no name',
+                                'StartDate' => $timer['StartDate'] ?? 'no start date',
+                                'EndDate' => $timer['EndDate'] ?? 'no end date',
+                                'ChannelName' => $timer['ChannelName'] ?? 'no channel name',
+                                'Status' => $timer['Status'] ?? 'no status',
+                                'UserId' => $timer['UserId'] ?? 'no user'
+                            ]));
+                        }
+                        
+                        // Calculate duration
+                        $duration = '-';
+                        if (isset($timer['StartDate']) && isset($timer['EndDate'])) {
+                            $start = strtotime($timer['StartDate']);
+                            $end = strtotime($timer['EndDate']);
+                            if ($start && $end) {
+                                $minutes = round(($end - $start) / 60);
+                                $hours = floor($minutes / 60);
+                                $remainingMinutes = $minutes % 60;
+                                if ($hours > 0) {
+                                    $duration = sprintf('%dh %dm', $hours, $remainingMinutes);
+                                } else {
+                                    $duration = sprintf('%dm', $minutes);
+                                }
+                            }
+                        }
+                        
+                        // Get channel name - timers should have this information
+                        $channelName = $timer['ChannelName'] ?? null;
+                        if (empty($channelName) && !empty($timer['ChannelId'])) {
+                            $channelName = 'Channel ' . $timer['ChannelId'];
+                        } elseif (empty($channelName)) {
+                            $channelName = 'Unknown Channel';
+                        }
+                        
+                        // Get user name
+                        $userName = null;
+                        if ($showUserInfo && !empty($timer['UserId']) && isset($userMap[$timer['UserId']])) {
+                            $userName = $userMap[$timer['UserId']];
+                        }
+                        
+                        // Determine status based on timing
+                        $status = 'Scheduled';
+                        $startTime = strtotime($timer['StartDate'] ?? '');
+                        $endTime = strtotime($timer['EndDate'] ?? '');
+                        $now = time();
+                        
+                        if ($startTime && $endTime) {
+                            if ($now >= $startTime && $now <= $endTime) {
+                                $status = 'Recording';
+                            } elseif ($now > $endTime) {
+                                $status = 'Completed';
+                            }
+                        }
+                        
+                        // Get series name and episode title - try multiple approaches
+                        $seriesName = '';
+                        $episodeTitle = '';
+                        
+                        // Check for episode title first
+                        if (!empty($timer['ProgramInfo']['EpisodeTitle'])) {
+                            $episodeTitle = $timer['ProgramInfo']['EpisodeTitle'];
+                        }
+                        
+                        // Get series name
+                        if (!empty($timer['SeriesName'])) {
+                            $seriesName = $timer['SeriesName'];
+                        } elseif (!empty($timer['ProgramInfo']['SeriesName'])) {
+                            $seriesName = $timer['ProgramInfo']['SeriesName'];
+                        } elseif (!empty($timer['ProgramInfo']['Name'])) {
+                            $seriesName = $timer['ProgramInfo']['Name'];
+                        } elseif (!empty($timer['Name'])) {
+                            $seriesName = $timer['Name'];
+                        }
+                        
+                        // Use episode title if available, otherwise use program/series name
+                        $displayName = $episodeTitle ? $episodeTitle : ($timer['Name'] ?? ($timer['ProgramInfo']['Name'] ?? 'Unknown Program'));
+                        
+                        $activity = [
+                            'date' => $timer['StartDate'] ?? '',
+                            'name' => $displayName,
+                            'seriesName' => $seriesName,
+                            'episodeTitle' => $episodeTitle,
+                            'channelName' => $channelName,
+                            'userName' => $userName,
+                            'duration' => $duration,
+                            'status' => $status,
+                            'type' => 'timer'
+                        ];
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Created activity for timer ' . ($index + 1) . ': ' . json_encode($activity));
+                        
+                        // Debug timer date parsing
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Timer date debug - StartDate: "' . ($timer['StartDate'] ?? 'null') . '", parsed startTime: ' . ($startTime ? date('Y-m-d H:i:s', $startTime) : 'failed to parse') . ', EndDate: "' . ($timer['EndDate'] ?? 'null') . '", parsed endTime: ' . ($endTime ? date('Y-m-d H:i:s', $endTime) : 'failed to parse') . ', current time: ' . date('Y-m-d H:i:s', $now) . ', calculated status: ' . $status);
+                        
+                        $scheduledRecordings[] = $activity;
+                    }
+                }
+            } catch (Exception $e) {
+                $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get timers for activity: ' . $e->getMessage());
+            }
+            
+            // Get completed recordings if enabled
+            if ($showCompleted) {
+                $recordingsUrl = $baseUrl . '/emby/LiveTv/Recordings?api_key=' . $this->config['embyToken'] . '&StartIndex=0&Limit=' . $maxCompletedItems . '&Fields=DateCreated,SeriesName,RunTimeTicks&SortBy=DateCreated&SortOrder=Descending';
+                $this->setLoggerChannel('EmbyLiveTVTracker')->info('Fetching completed recordings from URL: ' . $recordingsUrl);
+                try {
+                    $recordingsResponse = Requests::get($recordingsUrl, [], $options);
+                    if ($recordingsResponse->success) {
+                        $recordings = json_decode($recordingsResponse->body, true);
+                        $allRecordings = $recordings['Items'] ?? [];
+                        $this->setLoggerChannel('EmbyLiveTVTracker')->info('Retrieved ' . count($allRecordings) . ' completed recordings');
+                        
+                        foreach ($allRecordings as $recording) {
+                            if (isset($recording['DateCreated'])) {
+                                // Format duration
+                                $duration = '-';
+                                if (isset($recording['RunTimeTicks']) && $recording['RunTimeTicks'] > 0) {
+                                    $minutes = floor($recording['RunTimeTicks'] / 600000000);
+                                    $hours = floor($minutes / 60);
+                                    $remainingMinutes = $minutes % 60;
+                                    if ($hours > 0) {
+                                        $duration = sprintf('%dh %dm', $hours, $remainingMinutes);
+                                    } else {
+                                        $duration = sprintf('%dm', $minutes);
+                                    }
+                                }
+                                
+                                $completedRecordings[] = [
+                                    'date' => $recording['DateCreated'],
+                                    'name' => $recording['Name'] ?? 'Unknown Program',
+                                    'seriesName' => $recording['SeriesName'] ?? '',
+                                    'channelName' => 'Unknown Channel', // Completed recordings don't have reliable channel info
+                                    'userName' => null, // No user info available for completed recordings
+                                    'duration' => $duration,
+                                    'status' => 'Completed',
+                                    'type' => 'recording'
+                                ];
+                            }
+                        }
+                    }
+                } catch (Exception $e) {
+                    $this->setLoggerChannel('EmbyLiveTVTracker')->warning('Failed to get completed recordings: ' . $e->getMessage());
+                }
+            }
+            
+            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Final scheduled recordings count: ' . count($scheduledRecordings));
+            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Final completed recordings count: ' . count($completedRecordings));
+            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Sample scheduled: ' . json_encode(array_slice($scheduledRecordings, 0, 1)));
+            $this->setLoggerChannel('EmbyLiveTVTracker')->info('Sample completed: ' . json_encode(array_slice($completedRecordings, 0, 1)));
+            
+            $this->setAPIResponse('success', 'LiveTV activity retrieved successfully', 200, [
+                'scheduledRecordings' => $scheduledRecordings,
+                'completedRecordings' => $completedRecordings
+            ]);
+            return true;
+            
+        } catch (Exception $e) {
+            $this->setAPIResponse('error', 'Failed to retrieve LiveTV activity: ' . $e->getMessage(), 500);
+            return false;
+        }
+    }
+}
+?>

+ 13 - 0
api/v2/index.php

@@ -130,6 +130,19 @@ if (file_exists(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_S
 		}
 	}
 }
+/*
+ * Include Plugin routes from plugins/ directory (for external git repos)
+ */
+if (file_exists(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins')) {
+	$folder = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins';
+	$directoryIterator = new RecursiveDirectoryIterator($folder, FilesystemIterator::SKIP_DOTS);
+	$iteratorIterator = new RecursiveIteratorIterator($directoryIterator);
+	foreach ($iteratorIterator as $info) {
+		if ($info->getFilename() == 'routes.php' || ($info->getFilename() == 'api.php' && strpos($info->getPathname(), '/api/') !== false)) {
+			require_once $info->getPathname();
+		}
+	}
+}
 /*
  *
  *  This is the last defined api endpoint to catch all undefined endpoints

+ 10 - 0
api/v2/routes/connectionTester.php

@@ -134,6 +134,16 @@ $app->post('/test/emby', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->post('/test/embyLiveTVTracker', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->testConnectionEmbyLiveTVTracker($Organizr->apiData($request));
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
 $app->post('/test/jellyfin', function ($request, $response, $args) {
 	/**
 	 * @OA\Post(

+ 16 - 0
api/v2/routes/homepage.php

@@ -605,3 +605,19 @@ $app->post('/homepage/donate', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->get('/homepage/embyLiveTVTracker/stats', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getHomepageEmbyLiveTVStats();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/homepage/embyLiveTVTracker/activity', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getHomepageEmbyLiveTVActivity();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 1 - 1
api/v2/routes/plugins.php

@@ -66,4 +66,4 @@ $app->get('/plugins/marketplace', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-});
+});

BIN
plugins/images/homepage/embyLiveTVTracker.png