A recording of a live event crashed partway through: when its short Akamai token expired, the proxy's stale-alias check swapped the recording's mapping to the most recently registered deferred stream, of any content. Segment requests were then built against the wrong CDN path, returned 403, and ffmpeg exited, splitting the recording into several files. Proxy: - Never swap URN-backed livestreams; they refresh themselves from the API. Only consider swap candidates with the same URN. Recording lifecycle: - Complete a recording once its stream has been gone from the API for 5 minutes (broadcasts can end before ValidTo). - Fail a recording still waiting for its stream after ValidTo (or 12h after ValidFrom) instead of retrying forever. - Wait for a DVR window whose start= is still in the future instead of starting ffmpeg against a 400/404. - Keep the original start time across restarts; don't mark a recording failed on shutdown cancellation. Growing .ts recordings: - ffmpeg writes MPEG-TS (-c copy -copyts) to stdout and the plugin appends it to one file per recording, so restarts continue the same file and timeline. - The Recordings folder lists any recording with a file on disk, not only completed ones. - In-progress recordings are exposed as infinite streams reading through a new endpoint that follows the growing file (same pattern as Jellyfin's DVR), so they can be watched from the start like a livestream buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
242 lines
8.7 KiB
C#
242 lines
8.7 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 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();
|
|
}
|
|
}
|