using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller; using MediaBrowser.Controller.MediaEncoding; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for managing sport livestream recordings using ffmpeg. /// public class RecordingService : IRecordingService, IDisposable { private readonly ILogger _logger; private readonly ISRFApiClientFactory _apiClientFactory; private readonly IStreamProxyService _proxyService; private readonly IStreamUrlResolver _streamUrlResolver; private readonly IMediaCompositionFetcher _mediaCompositionFetcher; private readonly IServerApplicationHost _appHost; private readonly IMediaEncoder _mediaEncoder; private readonly ConcurrentDictionary _activeProcesses = new(); private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; private readonly SemaphoreSlim _persistLock = new(1, 1); private readonly SemaphoreSlim _processLock = new(1, 1); private List _recordings = new(); private bool _loaded; private bool _disposed; /// /// Initializes a new instance of the class. /// /// The logger. /// The API client factory. /// The stream proxy service. /// The stream URL resolver. /// The media composition fetcher. /// The application host. /// The media encoder for ffmpeg path. public RecordingService( ILogger logger, ISRFApiClientFactory apiClientFactory, IStreamProxyService proxyService, IStreamUrlResolver streamUrlResolver, IMediaCompositionFetcher mediaCompositionFetcher, IServerApplicationHost appHost, IMediaEncoder mediaEncoder) { _logger = logger; _apiClientFactory = apiClientFactory; _proxyService = proxyService; _streamUrlResolver = streamUrlResolver; _mediaCompositionFetcher = mediaCompositionFetcher; _appHost = appHost; _mediaEncoder = mediaEncoder; } private string GetDataFilePath() { var dataPath = Plugin.Instance?.DataFolderPath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jellyfin", "plugins", "SRFPlay"); Directory.CreateDirectory(dataPath); return Path.Combine(dataPath, "recordings.json"); } private string GetRecordingOutputPath() { var config = Plugin.Instance?.Configuration; var path = config?.RecordingOutputPath; if (string.IsNullOrWhiteSpace(path)) { path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings"); } Directory.CreateDirectory(path); return path; } private string GetServerBaseUrl() { var config = Plugin.Instance?.Configuration; if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl)) { return config.PublicServerUrl.TrimEnd('/'); } // For local ffmpeg access, use localhost directly return "http://localhost:8096"; } private async Task LoadRecordingsAsync() { if (_loaded) { return; } var filePath = GetDataFilePath(); if (File.Exists(filePath)) { try { var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); _recordings = JsonSerializer.Deserialize>(json) ?? new List(); _logger.LogInformation("Loaded {Count} recording entries from {Path}", _recordings.Count, filePath); } catch (Exception ex) { _logger.LogError(ex, "Failed to load recordings from {Path}", filePath); _recordings = new List(); } } _loaded = true; } private async Task SaveRecordingsAsync() { await _persistLock.WaitAsync().ConfigureAwait(false); try { var filePath = GetDataFilePath(); var json = JsonSerializer.Serialize(_recordings, _jsonOptions); await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Failed to save recordings"); } finally { _persistLock.Release(); } } /// public async Task> GetUpcomingScheduleAsync(CancellationToken cancellationToken) { var config = Plugin.Instance?.Configuration; var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant(); using var apiClient = _apiClientFactory.CreateClient(); var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); if (livestreams == null) { return Array.Empty(); } // Filter to only future/current livestreams that aren't blocked return livestreams .Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo > DateTime.UtcNow)) .OrderBy(ls => ls.ValidFrom) .ToList(); } /// public async Task ScheduleRecordingAsync(string urn, CancellationToken cancellationToken) { await LoadRecordingsAsync().ConfigureAwait(false); // Check if already scheduled var existing = _recordings.FirstOrDefault(r => r.Urn == urn && r.State is RecordingState.Scheduled or RecordingState.WaitingForStream or RecordingState.Recording); if (existing != null) { _logger.LogInformation("Recording already exists for URN {Urn} in state {State}", urn, existing.State); return existing; } // Fetch metadata for the URN var config = Plugin.Instance?.Configuration; var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant(); using var apiClient = _apiClientFactory.CreateClient(); var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); var program = livestreams?.FirstOrDefault(ls => ls.Urn == urn); var entry = new RecordingEntry { Id = Guid.NewGuid().ToString("N"), Urn = urn, Title = program?.Title ?? urn, Description = program?.Lead ?? program?.Description, ImageUrl = program?.ImageUrl, ValidFrom = program?.ValidFrom, ValidTo = program?.ValidTo, State = RecordingState.Scheduled, CreatedAt = DateTime.UtcNow }; _recordings.Add(entry); await SaveRecordingsAsync().ConfigureAwait(false); _logger.LogInformation("Scheduled recording for '{Title}' (URN: {Urn}, starts: {ValidFrom})", entry.Title, urn, entry.ValidFrom); return entry; } /// public bool CancelRecording(string recordingId) { var entry = _recordings.FirstOrDefault(r => r.Id == recordingId); if (entry == null) { return false; } if (entry.State == RecordingState.Recording) { StopFfmpeg(recordingId); } entry.State = RecordingState.Cancelled; entry.RecordingEndedAt = DateTime.UtcNow; _ = SaveRecordingsAsync(); _logger.LogInformation("Cancelled recording '{Title}' ({Id})", entry.Title, recordingId); return true; } /// public bool StopRecording(string recordingId) { var entry = _recordings.FirstOrDefault(r => r.Id == recordingId && r.State == RecordingState.Recording); if (entry == null) { return false; } StopFfmpeg(recordingId); entry.State = RecordingState.Completed; entry.RecordingEndedAt = DateTime.UtcNow; if (entry.OutputPath != null && File.Exists(entry.OutputPath)) { entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length; } _ = SaveRecordingsAsync(); _logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId); return true; } /// public IReadOnlyList GetRecordings(RecordingState? stateFilter) { // Ensure loaded synchronously for simple reads if (!_loaded) { LoadRecordingsAsync().GetAwaiter().GetResult(); } if (stateFilter.HasValue) { return _recordings.Where(r => r.State == stateFilter.Value).OrderByDescending(r => r.CreatedAt).ToList(); } return _recordings.OrderByDescending(r => r.CreatedAt).ToList(); } /// public bool DeleteRecording(string recordingId, bool deleteFile) { var entry = _recordings.FirstOrDefault(r => r.Id == recordingId); if (entry == null) { return false; } if (entry.State == RecordingState.Recording) { StopFfmpeg(recordingId); } if (deleteFile && !string.IsNullOrEmpty(entry.OutputPath) && File.Exists(entry.OutputPath)) { try { File.Delete(entry.OutputPath); _logger.LogInformation("Deleted recording file: {Path}", entry.OutputPath); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete recording file: {Path}", entry.OutputPath); } } _recordings.Remove(entry); _ = SaveRecordingsAsync(); _logger.LogInformation("Deleted recording entry '{Title}' ({Id})", entry.Title, recordingId); return true; } /// public async Task ProcessRecordingsAsync(CancellationToken cancellationToken) { // Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes if (!await _processLock.WaitAsync(0, CancellationToken.None).ConfigureAwait(false)) { _logger.LogDebug("ProcessRecordingsAsync already running, skipping"); return; } try { await ProcessRecordingsCoreAsync(cancellationToken).ConfigureAwait(false); } finally { _processLock.Release(); } } private async Task ProcessRecordingsCoreAsync(CancellationToken cancellationToken) { await LoadRecordingsAsync().ConfigureAwait(false); var now = DateTime.UtcNow; var changed = false; foreach (var entry in _recordings.ToList()) { switch (entry.State) { case RecordingState.Scheduled: case RecordingState.WaitingForStream: // Check if it's time to start recording if (entry.ValidFrom.HasValue && entry.ValidFrom.Value <= now.AddMinutes(2)) { changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false); } break; case RecordingState.Recording: // Check if recording should stop (ValidTo reached or process died) if (entry.ValidTo.HasValue && entry.ValidTo.Value <= now) { _logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title); StopFfmpeg(entry.Id); entry.State = RecordingState.Completed; entry.RecordingEndedAt = now; if (entry.OutputPath != null && File.Exists(entry.OutputPath)) { entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length; } changed = true; } else if (!_activeProcesses.ContainsKey(entry.Id)) { // ffmpeg process died unexpectedly — try to restart _logger.LogWarning("ffmpeg process for '{Title}' is no longer running, attempting restart", entry.Title); changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false); } break; } } if (changed) { await SaveRecordingsAsync().ConfigureAwait(false); } } private async Task TryStartRecordingAsync(RecordingEntry entry, CancellationToken cancellationToken) { try { // Fetch the media composition to get the stream URL var mediaComposition = await _mediaCompositionFetcher.GetMediaCompositionAsync(entry.Urn, cacheDurationOverride: 2, cancellationToken: cancellationToken).ConfigureAwait(false); var chapter = mediaComposition?.ChapterList is { Count: > 0 } list ? list[0] : null; if (chapter == null) { _logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title); entry.State = RecordingState.WaitingForStream; return true; } var config = Plugin.Instance?.Configuration; var quality = config?.QualityPreference ?? Configuration.QualityPreference.Auto; var streamUrl = _streamUrlResolver.GetStreamUrl(chapter, quality); if (string.IsNullOrEmpty(streamUrl)) { _logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title); entry.State = RecordingState.WaitingForStream; return true; } // Register the stream with the proxy so we can use the proxy URL var itemId = $"rec_{entry.Id}"; var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); _proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream); // Build proxy URL for ffmpeg (use localhost for local access) var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8"; // Build output file path var safeTitle = SanitizeFileName(entry.Title); var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture); var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv"); entry.OutputPath = outputPath; // Start ffmpeg StartFfmpeg(entry.Id, proxyUrl, outputPath); entry.State = RecordingState.Recording; entry.RecordingStartedAt = DateTime.UtcNow; _logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath); return true; } catch (Exception ex) { _logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title); entry.State = RecordingState.Failed; entry.ErrorMessage = ex.Message; return true; } } private void StartFfmpeg(string recordingId, string inputUrl, string outputPath) { var process = new Process { StartInfo = new ProcessStartInfo { FileName = _mediaEncoder.EncoderPath, Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"", UseShellExecute = false, RedirectStandardInput = true, RedirectStandardError = true, CreateNoWindow = true }, EnableRaisingEvents = true }; process.ErrorDataReceived += (_, args) => { if (!string.IsNullOrEmpty(args.Data)) { _logger.LogDebug("ffmpeg [{RecordingId}]: {Data}", recordingId, args.Data); } }; process.Exited += (_, _) => { _logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, process.ExitCode); _activeProcesses.TryRemove(recordingId, out _); }; process.Start(); process.BeginErrorReadLine(); _activeProcesses[recordingId] = process; _logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments); } private void StopFfmpeg(string recordingId) { if (_activeProcesses.TryRemove(recordingId, out var process)) { try { if (!process.HasExited) { // Send 'q' to ffmpeg stdin for graceful shutdown process.StandardInput.Write("q"); process.StandardInput.Flush(); if (!process.WaitForExit(10000)) { _logger.LogWarning("ffmpeg did not exit gracefully for {RecordingId}, killing", recordingId); process.Kill(true); } } process.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "Error stopping ffmpeg for recording {RecordingId}", recordingId); } } } private static string SanitizeFileName(string name) { var invalid = Path.GetInvalidFileNameChars(); var sanitized = string.Join("_", name.Split(invalid, StringSplitOptions.RemoveEmptyEntries)); // Also replace spaces and other problematic chars sanitized = Regex.Replace(sanitized, @"[\s]+", "_"); return sanitized.Length > 100 ? sanitized[..100] : sanitized; } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Releases resources. /// /// True to release managed resources. protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { foreach (var kvp in _activeProcesses) { StopFfmpeg(kvp.Key); } _persistLock.Dispose(); _processLock.Dispose(); } _disposed = true; } }