diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs new file mode 100644 index 0000000..697974c --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs @@ -0,0 +1,94 @@ +using System; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.SRFPlay.Api.Models; + +/// +/// A scheduled or active recording. +/// +public class RecordingEntry +{ + /// + /// Gets or sets the unique recording ID. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the SRF URN. + /// + [JsonPropertyName("urn")] + public string Urn { get; set; } = string.Empty; + + /// + /// Gets or sets the title. + /// + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the description. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Gets or sets the image URL. + /// + [JsonPropertyName("imageUrl")] + public string? ImageUrl { get; set; } + + /// + /// Gets or sets when the livestream starts. + /// + [JsonPropertyName("validFrom")] + public DateTime? ValidFrom { get; set; } + + /// + /// Gets or sets when the livestream ends. + /// + [JsonPropertyName("validTo")] + public DateTime? ValidTo { get; set; } + + /// + /// Gets or sets the recording state. + /// + [JsonPropertyName("state")] + public RecordingState State { get; set; } = RecordingState.Scheduled; + + /// + /// Gets or sets the output file path. + /// + [JsonPropertyName("outputPath")] + public string? OutputPath { get; set; } + + /// + /// Gets or sets when the recording actually started. + /// + [JsonPropertyName("recordingStartedAt")] + public DateTime? RecordingStartedAt { get; set; } + + /// + /// Gets or sets when the recording ended. + /// + [JsonPropertyName("recordingEndedAt")] + public DateTime? RecordingEndedAt { get; set; } + + /// + /// Gets or sets the file size in bytes. + /// + [JsonPropertyName("fileSizeBytes")] + public long? FileSizeBytes { get; set; } + + /// + /// Gets or sets the error message if recording failed. + /// + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } + + /// + /// Gets or sets when this entry was created. + /// + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingState.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingState.cs new file mode 100644 index 0000000..e1ec683 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingState.cs @@ -0,0 +1,25 @@ +namespace Jellyfin.Plugin.SRFPlay.Api.Models; + +/// +/// State of a recording. +/// +public enum RecordingState +{ + /// Scheduled for future recording. + Scheduled, + + /// Waiting for stream to become available. + WaitingForStream, + + /// Currently recording. + Recording, + + /// Recording completed successfully. + Completed, + + /// Recording failed. + Failed, + + /// Recording was cancelled. + Cancelled +} diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs index abd4c93..b62c5a9 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs @@ -156,4 +156,9 @@ public class PluginConfiguration : BasePluginConfiguration /// When enabled, generates custom thumbnails instead of using SRF-provided images. /// public bool GenerateTitleCards { get; set; } + + /// + /// Gets or sets the output directory for sport livestream recordings. + /// + public string RecordingOutputPath { get; set; } = string.Empty; } diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html index 31379ad..fa0bb56 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html +++ b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html @@ -90,6 +90,13 @@
Password for proxy authentication (leave empty if not required)

+

Recording Settings

+
+ + +
Directory where sport livestream recordings will be saved (requires ffmpeg)
+
+

Network Settings

@@ -106,6 +113,31 @@
+ +
+

Sport Livestream Recordings

+ +

Upcoming Sport Livestreams

+
Select livestreams to record. The recording starts automatically when the stream goes live.
+
+

Loading schedule...

+
+ + +

Scheduled & Active Recordings

+
+

Loading...

+
+ +

Completed Recordings

+
+

Loading...

+
+ diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs new file mode 100644 index 0000000..d5f82f4 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs @@ -0,0 +1,152 @@ +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.SRFPlay.Controllers; + +/// +/// Controller for managing sport livestream recordings. +/// +[ApiController] +[Route("Plugins/SRFPlay/Recording")] +[Authorize] +public class RecordingController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IRecordingService _recordingService; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The recording service. + public RecordingController( + ILogger logger, + IRecordingService recordingService) + { + _logger = logger; + _recordingService = recordingService; + } + + /// + /// Gets upcoming sport livestreams available for recording. + /// + /// The cancellation token. + /// List of upcoming livestreams. + [HttpGet("Schedule")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetSchedule(CancellationToken cancellationToken) + { + var schedule = await _recordingService.GetUpcomingScheduleAsync(cancellationToken).ConfigureAwait(false); + return Ok(schedule); + } + + /// + /// Schedules a livestream for recording by URN. + /// + /// The SRF URN. + /// The cancellation token. + /// The created recording entry. + [HttpPost("Schedule/{urn}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ScheduleRecording( + [FromRoute] string urn, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(urn)) + { + return BadRequest("URN is required"); + } + + // URN comes URL-encoded with colons, decode it + urn = System.Net.WebUtility.UrlDecode(urn); + + _logger.LogInformation("Scheduling recording for URN: {Urn}", urn); + var entry = await _recordingService.ScheduleRecordingAsync(urn, cancellationToken).ConfigureAwait(false); + return Ok(entry); + } + + /// + /// Cancels a scheduled recording. + /// + /// The recording ID. + /// OK or NotFound. + [HttpDelete("Schedule/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult CancelRecording([FromRoute] string id) + { + return _recordingService.CancelRecording(id) ? Ok() : NotFound(); + } + + /// + /// Gets currently active recordings. + /// + /// List of active recordings. + [HttpGet("Active")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult GetActiveRecordings() + { + var active = _recordingService.GetRecordings(RecordingState.Recording); + return Ok(active); + } + + /// + /// Stops an active recording. + /// + /// The recording ID. + /// OK or NotFound. + [HttpPost("Active/{id}/Stop")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult StopRecording([FromRoute] string id) + { + return _recordingService.StopRecording(id) ? Ok() : NotFound(); + } + + /// + /// Gets completed recordings. + /// + /// List of completed recordings. + [HttpGet("Completed")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult GetCompletedRecordings() + { + var completed = _recordingService.GetRecordings(RecordingState.Completed); + return Ok(completed); + } + + /// + /// Gets all recordings (all states). + /// + /// List of all recordings. + [HttpGet("All")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult GetAllRecordings() + { + var all = _recordingService.GetRecordings(); + return Ok(all); + } + + /// + /// Deletes a completed recording and its file. + /// + /// The recording ID. + /// Whether to delete the file too. + /// OK or NotFound. + [HttpDelete("Completed/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult DeleteRecording( + [FromRoute] string id, + [FromQuery] bool deleteFile = true) + { + return _recordingService.DeleteRecording(id, deleteFile) ? Ok() : NotFound(); + } +} diff --git a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/RecordingSchedulerTask.cs b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/RecordingSchedulerTask.cs new file mode 100644 index 0000000..0a92500 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/RecordingSchedulerTask.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks; + +/// +/// Scheduled task that checks and manages sport livestream recordings. +/// Runs every 2 minutes to start scheduled recordings when streams go live +/// and stop recordings when they end. +/// +public class RecordingSchedulerTask : IScheduledTask +{ + private readonly ILogger _logger; + private readonly IRecordingService _recordingService; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The recording service. + public RecordingSchedulerTask( + ILogger logger, + IRecordingService recordingService) + { + _logger = logger; + _recordingService = recordingService; + } + + /// + public string Name => "Process SRF Play Recordings"; + + /// + public string Description => "Checks scheduled recordings and starts/stops them as needed"; + + /// + public string Category => "SRF Play"; + + /// + public string Key => "SRFPlayRecordingScheduler"; + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + _logger.LogDebug("Processing SRF Play recordings"); + progress?.Report(0); + + try + { + await _recordingService.ProcessRecordingsAsync(cancellationToken).ConfigureAwait(false); + progress?.Report(100); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing recordings"); + throw; + } + } + + /// + public IEnumerable GetDefaultTriggers() + { + return new[] + { + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromMinutes(2).Ticks + } + }; + } +} diff --git a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs index fad5557..0ef6988 100644 --- a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs @@ -41,9 +41,13 @@ public class ServiceRegistrator : IPluginServiceRegistrator // Register media source provider serviceCollection.AddSingleton(); + // Register recording service + serviceCollection.AddSingleton(); + // Register scheduled tasks serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // Register channel - must register as IChannel interface for Jellyfin to discover it serviceCollection.AddSingleton(); diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs new file mode 100644 index 0000000..44d5413 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3; + +namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; + +/// +/// Service for managing sport livestream recordings. +/// +public interface IRecordingService +{ + /// + /// Gets upcoming sport livestreams that can be recorded. + /// + /// The cancellation token. + /// List of upcoming sport livestreams. + Task> GetUpcomingScheduleAsync(CancellationToken cancellationToken = default); + + /// + /// Schedules a livestream for recording. + /// + /// The SRF URN to record. + /// The cancellation token. + /// The created recording entry. + Task ScheduleRecordingAsync(string urn, CancellationToken cancellationToken = default); + + /// + /// Cancels a scheduled recording. + /// + /// The recording ID. + /// True if cancelled. + bool CancelRecording(string recordingId); + + /// + /// Stops an active recording. + /// + /// The recording ID. + /// True if stopped. + bool StopRecording(string recordingId); + + /// + /// Gets all recordings by state. + /// + /// Optional state filter. + /// List of matching recording entries. + IReadOnlyList GetRecordings(RecordingState? stateFilter = null); + + /// + /// Deletes a completed recording (entry and optionally the file). + /// + /// The recording ID. + /// Whether to delete the file too. + /// True if deleted. + bool DeleteRecording(string recordingId, bool deleteFile = true); + + /// + /// Checks scheduled recordings and starts/stops them as needed. + /// Called periodically by the scheduler task. + /// + /// The cancellation token. + /// A task representing the async operation. + Task ProcessRecordingsAsync(CancellationToken cancellationToken = default); +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs new file mode 100644 index 0000000..acfb4f1 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs @@ -0,0 +1,517 @@ +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 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 ConcurrentDictionary _activeProcesses = new(); + private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; + private readonly SemaphoreSlim _persistLock = 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. + public RecordingService( + ILogger logger, + ISRFApiClientFactory apiClientFactory, + IStreamProxyService proxyService, + IStreamUrlResolver streamUrlResolver, + IMediaCompositionFetcher mediaCompositionFetcher, + IServerApplicationHost appHost) + { + _logger = logger; + _apiClientFactory = apiClientFactory; + _proxyService = proxyService; + _streamUrlResolver = streamUrlResolver; + _mediaCompositionFetcher = mediaCompositionFetcher; + _appHost = appHost; + } + + 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) + { + 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 = "ffmpeg", + 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(); + } + + _disposed = true; + } +}