using System; using System.IO; using System.Linq; using System.Reflection; 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 { /// How long a followed stream waits at the end of an in-progress file before giving up. private static readonly TimeSpan FollowIdleTimeout = TimeSpan.FromMinutes(2); 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; } /// /// Serves the recording manager page accessible to any authenticated user. /// /// The recording manager HTML page. [HttpGet("Page")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public IActionResult GetRecordingPage() { var assembly = Assembly.GetExecutingAssembly(); var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Configuration.recordingPage.html"); if (resourceStream == null) { return NotFound("Recording page not found"); } return File(resourceStream, "text/html"); } /// /// Streams a recording's .ts file. While the recording is still in progress this keeps /// following the file as it grows, so it can be watched like a livestream buffer. /// /// The recording ID. /// The cancellation token. /// The MPEG-TS stream. [HttpGet("{id}/stream")] [AllowAnonymous] // Fetched server-side by Jellyfin/ffmpeg without a token, like the stream proxy [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetRecordingStream(string id, CancellationToken cancellationToken) { // The path always comes from our own recording list (which only contains existing files), // never from the request var entry = _recordingService.GetPlayableRecordings().FirstOrDefault(r => r.Id == id); if (entry?.OutputPath is not { } path) { return NotFound(); } Response.ContentType = "video/mp2t"; var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); await using (input.ConfigureAwait(false)) { var buffer = new byte[81920]; var lastDataAt = DateTime.UtcNow; try { while (true) { var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); if (read > 0) { await Response.Body.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); lastDataAt = DateTime.UtcNow; continue; } // At the end of the file: keep following while it's still being written. The idle // limit covers an ffmpeg restart but ends the stream if recording has stalled. var stillRecording = _recordingService.GetRecording(id)?.IsInProgress == true; if (!stillRecording || DateTime.UtcNow - lastDataAt > FollowIdleTimeout) { break; } await Task.Delay(500, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) { // Client went away } } return new EmptyResult(); } /// /// 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(); } }