When no custom directory is set, recordings go to an "SRF Recordings" folder in the chosen library, or the first TV Shows library, so they show up without adding a library by hand. The settings page gets a library picker, a check that the directory is writable, and help for sandboxed systemd services. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
9.2 KiB
C#
255 lines
9.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Controller for managing sport livestream recordings.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("Plugins/SRFPlay/Recording")]
|
|
[Authorize]
|
|
public class RecordingController : ControllerBase
|
|
{
|
|
/// <summary>How long a followed stream waits at the end of an in-progress file before giving up.</summary>
|
|
private static readonly TimeSpan FollowIdleTimeout = TimeSpan.FromMinutes(2);
|
|
|
|
private readonly ILogger<RecordingController> _logger;
|
|
private readonly IRecordingService _recordingService;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="RecordingController"/> class.
|
|
/// </summary>
|
|
/// <param name="logger">The logger.</param>
|
|
/// <param name="recordingService">The recording service.</param>
|
|
public RecordingController(
|
|
ILogger<RecordingController> logger,
|
|
IRecordingService recordingService)
|
|
{
|
|
_logger = logger;
|
|
_recordingService = recordingService;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serves the recording manager page accessible to any authenticated user.
|
|
/// </summary>
|
|
/// <returns>The recording manager HTML page.</returns>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="id">The recording ID.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
/// <returns>The MPEG-TS stream.</returns>
|
|
[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<IActionResult> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets upcoming sport livestreams available for recording.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
/// <returns>List of upcoming livestreams.</returns>
|
|
[HttpGet("Schedule")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> GetSchedule(CancellationToken cancellationToken)
|
|
{
|
|
var schedule = await _recordingService.GetUpcomingScheduleAsync(cancellationToken).ConfigureAwait(false);
|
|
return Ok(schedule);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schedules a livestream for recording by URN.
|
|
/// </summary>
|
|
/// <param name="urn">The SRF URN.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
/// <returns>The created recording entry.</returns>
|
|
[HttpPost("Schedule/{urn}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancels a scheduled recording.
|
|
/// </summary>
|
|
/// <param name="id">The recording ID.</param>
|
|
/// <returns>OK or NotFound.</returns>
|
|
[HttpDelete("Schedule/{id}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public IActionResult CancelRecording([FromRoute] string id)
|
|
{
|
|
return _recordingService.CancelRecording(id) ? Ok() : NotFound();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets currently active recordings.
|
|
/// </summary>
|
|
/// <returns>List of active recordings.</returns>
|
|
[HttpGet("Active")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public IActionResult GetActiveRecordings()
|
|
{
|
|
var active = _recordingService.GetRecordings(RecordingState.Recording);
|
|
return Ok(active);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops an active recording.
|
|
/// </summary>
|
|
/// <param name="id">The recording ID.</param>
|
|
/// <returns>OK or NotFound.</returns>
|
|
[HttpPost("Active/{id}/Stop")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public IActionResult StopRecording([FromRoute] string id)
|
|
{
|
|
return _recordingService.StopRecording(id) ? Ok() : NotFound();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets completed recordings.
|
|
/// </summary>
|
|
/// <returns>List of completed recordings.</returns>
|
|
[HttpGet("Completed")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public IActionResult GetCompletedRecordings()
|
|
{
|
|
var completed = _recordingService.GetRecordings(RecordingState.Completed);
|
|
return Ok(completed);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all recordings (all states).
|
|
/// </summary>
|
|
/// <returns>List of all recordings.</returns>
|
|
[HttpGet("All")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public IActionResult GetAllRecordings()
|
|
{
|
|
var all = _recordingService.GetRecordings();
|
|
return Ok(all);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a completed recording and its file.
|
|
/// </summary>
|
|
/// <param name="id">The recording ID.</param>
|
|
/// <param name="deleteFile">Whether to delete the file too.</param>
|
|
/// <returns>OK or NotFound.</returns>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows where recordings will be written and whether the server can write there.
|
|
/// </summary>
|
|
/// <returns>The resolved output directory and write-test result.</returns>
|
|
[HttpGet("OutputInfo")]
|
|
[Authorize(Policy = Policies.RequiresElevation)]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public ActionResult<RecordingOutputInfo> GetOutputInfo()
|
|
{
|
|
return Ok(_recordingService.GetOutputInfo());
|
|
}
|
|
}
|