using CopilotCoordinator.Models; namespace CopilotCoordinator.Services; /// /// Sequence execution engine. Ported from legacy SequenceService.cs. /// Runs camera rotation sequences by sending CrossSwitch commands to bridges. /// public class SequenceRunner { private readonly Dictionary _running = new(); private readonly SemaphoreSlim _lock = new(1, 1); private readonly WsBroadcaster _broadcaster; private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _config; private readonly ILogger _logger; // Sequence definitions loaded from config (in production, loaded from JSON file) private List _sequences = new(); private List _categories = new(); public SequenceRunner(WsBroadcaster broadcaster, IHttpClientFactory httpClientFactory, IConfiguration config, ILogger logger) { _broadcaster = broadcaster; _httpClientFactory = httpClientFactory; _config = config; _logger = logger; } public void LoadSequences(List sequences, List categories) { _sequences = sequences; _categories = categories; _logger.LogInformation("Loaded {Count} sequences in {CatCount} categories", sequences.Count, categories.Count); } public IReadOnlyList GetSequences(int? categoryId = null) { return categoryId.HasValue ? _sequences.Where(s => s.CategoryId == categoryId.Value).ToList() : _sequences; } public IReadOnlyList GetCategories() => _categories; public async Task Start(int viewerId, int sequenceId) { var sequence = _sequences.FirstOrDefault(s => s.Id == sequenceId); if (sequence == null) { _logger.LogError("Sequence {SequenceId} not found", sequenceId); return null; } await _lock.WaitAsync(); try { // Stop existing sequence on this viewer if (_running.TryGetValue(viewerId, out var existing)) { existing.Cts.Cancel(); _running.Remove(viewerId); } var cts = new CancellationTokenSource(); var startedAt = DateTime.UtcNow; _running[viewerId] = (cts, sequenceId, startedAt); // Start the sequence task _ = Task.Run(() => RunSequenceLoop(viewerId, sequence, cts.Token), cts.Token); _logger.LogInformation("Started sequence {SequenceId} on viewer {ViewerId}", sequenceId, viewerId); var state = new RunningSequence(viewerId, sequenceId, startedAt, 0); await _broadcaster.Broadcast("sequence_started", state); return state; } finally { _lock.Release(); } } public async Task Stop(int viewerId) { await _lock.WaitAsync(); try { if (_running.TryGetValue(viewerId, out var existing)) { existing.Cts.Cancel(); _running.Remove(viewerId); _logger.LogInformation("Stopped sequence on viewer {ViewerId}", viewerId); } } finally { _lock.Release(); } await _broadcaster.Broadcast("sequence_stopped", new { viewerId }); } public async Task> GetRunning() { await _lock.WaitAsync(); try { return _running.Select(kvp => new RunningSequence( kvp.Key, kvp.Value.SequenceId, kvp.Value.StartedAt, 0 )).ToList(); } finally { _lock.Release(); } } /// /// Core sequence loop. Ported from legacy SequencePeriodTask. /// Cycles through cameras, sending CrossSwitch to the appropriate bridge. /// private async Task RunSequenceLoop(int viewerId, SequenceDefinition sequence, CancellationToken ct) { _logger.LogDebug("Sequence loop started: viewer {ViewerId}, sequence {SequenceId}", viewerId, sequence.Id); try { while (!ct.IsCancellationRequested) { for (int i = 0; i < sequence.Cameras.Count && !ct.IsCancellationRequested; i++) { var camera = sequence.Cameras[i]; // Send CrossSwitch via bridge await SendCrossSwitch(viewerId, camera); _logger.LogDebug("Sequence {SeqId} viewer {ViewerId}: switched to camera {Camera}", sequence.Id, viewerId, camera); await Task.Delay(TimeSpan.FromSeconds(sequence.IntervalSeconds), ct); } } } catch (OperationCanceledException) { _logger.LogInformation("Sequence loop cancelled: viewer {ViewerId}", viewerId); } catch (Exception ex) { _logger.LogError(ex, "Sequence loop error: viewer {ViewerId}", viewerId); } } /// /// Send CrossSwitch command to the appropriate bridge. /// Determines bridge URL from camera ID ranges in configuration. /// private async Task SendCrossSwitch(int viewerId, int camera) { try { // Determine which bridge handles this camera based on configured ranges var bridgeUrl = ResolveBridgeUrl(camera); if (bridgeUrl == null) { _logger.LogWarning("No bridge found for camera {Camera}", camera); return; } var client = _httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(5); var response = await client.PostAsJsonAsync($"{bridgeUrl}/viewer/connect-live", new { Viewer = viewerId, Channel = camera }); if (!response.IsSuccessStatusCode) { _logger.LogWarning("CrossSwitch failed: viewer {Viewer} camera {Camera} status {Status}", viewerId, camera, response.StatusCode); } } catch (Exception ex) { _logger.LogError(ex, "CrossSwitch error: viewer {ViewerId} camera {Camera}", viewerId, camera); } } private string? ResolveBridgeUrl(int camera) { // Read bridge mappings from config: "Bridges" section with camera ranges var bridges = _config.GetSection("Bridges").GetChildren(); foreach (var bridge in bridges) { var start = bridge.GetValue("CameraRangeStart"); var end = bridge.GetValue("CameraRangeEnd"); if (camera >= start && camera <= end) return bridge.GetValue("Url"); } // Fallback: first bridge return bridges.FirstOrDefault()?.GetValue("Url"); } }