Fix recordings dying mid-stream; record to growing .ts with live buffer playback
🏗️ Build Plugin / build (push) Successful in 12m44s
Nightly Build / nightly-build (push) Successful in 2m49s
🧪 Test Plugin / test (push) Successful in 11m18s
🚀 Release Plugin / build-and-release (push) Successful in 12m24s

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>
This commit is contained in:
2026-09-26 10:14:24 -04:00
co-authored by Claude Opus 5.5
parent c6a1fd9f63
commit 71a65aab6e
6 changed files with 353 additions and 71 deletions
@@ -1,4 +1,6 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -19,6 +21,9 @@ namespace Jellyfin.Plugin.SRFPlay.Controllers;
[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;
@@ -56,6 +61,67 @@ public class RecordingController : ControllerBase
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>