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 MediaBrowser.Common.Api;
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();
}
///
/// Shows where recordings will be written and whether the server can write there.
///
/// The resolved output directory and write-test result.
[HttpGet("OutputInfo")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult GetOutputInfo()
{
return Ok(_recordingService.GetOutputInfo());
}
}