diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs
index b1362a5..62433da 100644
--- a/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs
+++ b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs
@@ -81,6 +81,13 @@ public class RecordingEntry
[JsonPropertyName("recordingEndedAt")]
public DateTime? RecordingEndedAt { get; set; }
+ ///
+ /// Gets or sets when the stream disappeared from the API after recording had started.
+ /// Used to detect that a broadcast ended before its scheduled ValidTo.
+ ///
+ [JsonPropertyName("streamLostAt")]
+ public DateTime? StreamLostAt { get; set; }
+
///
/// Gets or sets the file size in bytes.
///
@@ -98,4 +105,10 @@ public class RecordingEntry
///
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// Gets a value indicating whether the output file may still grow (recording, or waiting to resume).
+ ///
+ [JsonIgnore]
+ public bool IsInProgress => State is RecordingState.Recording or RecordingState.WaitingForStream;
}
diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs b/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs
index fc6b8a5..416cc58 100644
--- a/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs
+++ b/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs
@@ -28,6 +28,8 @@ namespace Jellyfin.Plugin.SRFPlay.Channels;
///
public abstract class SrgChannelBase : IChannel, IHasCacheKey
{
+ private const string InProgressPrefix = "● REC · ";
+
private readonly ILogger _logger;
private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver;
@@ -320,15 +322,10 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
{
var items = new List();
var unit = Unit.ToLowerString();
- var recordings = _recordingService.GetRecordings(RecordingState.Completed);
+ var serverBaseUrl = _mediaSourceFactory.GetServerBaseUrl();
- foreach (var recording in recordings)
+ foreach (var recording in _recordingService.GetPlayableRecordings())
{
- if (string.IsNullOrEmpty(recording.OutputPath) || !System.IO.File.Exists(recording.OutputPath))
- {
- continue;
- }
-
// Only show recordings belonging to this unit. Recordings created before the
// BusinessUnit tag existed have an empty value and are shown everywhere so they
// are not lost.
@@ -338,43 +335,72 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
continue;
}
- var fileInfo = new System.IO.FileInfo(recording.OutputPath);
+ var outputPath = recording.OutputPath!;
var itemId = $"recording_{recording.Id}";
+ var container = System.IO.Path.GetExtension(outputPath).TrimStart('.').ToLowerInvariant();
+ var name = recording.IsInProgress ? $"{InProgressPrefix}{recording.Title}" : recording.Title;
- var mediaSource = new MediaSourceInfo
+ MediaSourceInfo mediaSource;
+ if (recording.IsInProgress)
{
- Id = itemId,
- Name = recording.Title,
- Path = recording.OutputPath,
- Protocol = MediaProtocol.File,
- Container = "mkv",
- SupportsDirectPlay = true,
- SupportsDirectStream = true,
- SupportsTranscoding = true,
- IsRemote = false,
- Size = fileInfo.Length,
- Type = MediaSourceType.Default
- };
+ // Same approach as Jellyfin's own DVR: the file is still growing, so treat it as a live
+ // stream and have ffmpeg read it through an endpoint that follows the file.
+ mediaSource = new MediaSourceInfo
+ {
+ Id = itemId,
+ Name = name,
+ Path = outputPath,
+ Protocol = MediaProtocol.File,
+ EncoderPath = $"{serverBaseUrl}/Plugins/SRFPlay/Recording/{recording.Id}/stream",
+ EncoderProtocol = MediaProtocol.Http,
+ Container = container,
+ SupportsDirectPlay = false,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true,
+ IsInfiniteStream = true,
+ IgnoreDts = true,
+ IgnoreIndex = true,
+ IsRemote = false,
+ Type = MediaSourceType.Default
+ };
+ }
+ else
+ {
+ mediaSource = new MediaSourceInfo
+ {
+ Id = itemId,
+ Name = name,
+ Path = outputPath,
+ Protocol = MediaProtocol.File,
+ Container = container,
+ SupportsDirectPlay = true,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true,
+ IsRemote = false,
+ Size = new System.IO.FileInfo(outputPath).Length,
+ Type = MediaSourceType.Default
+ };
+ }
var item = new ChannelItemInfo
{
Id = itemId,
- Name = recording.Title,
+ Name = name,
Overview = recording.Description,
Type = ChannelItemType.Media,
ContentType = ChannelMediaContentType.Movie,
MediaType = ChannelMediaType.Video,
DateCreated = recording.RecordingStartedAt,
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
- ? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
- : CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
+ ? CreateProxiedImageUrl(recording.ImageUrl, serverBaseUrl)
+ : CreatePlaceholderImageUrl(recording.Title, serverBaseUrl),
MediaSources = new List { mediaSource }
};
items.Add(item);
}
- _logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
+ _logger.LogInformation("Returning {Count} recordings as channel items", items.Count);
return items;
}
@@ -492,8 +518,10 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
- var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
- return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
+ // Include in-progress count so items switch from live to finished sources when a recording ends
+ var recordings = _recordingService.GetPlayableRecordings();
+ var recordingKey = $"{recordings.Count}_{recordings.Count(r => r.IsInProgress)}";
+ return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingKey}";
}
private async Task> ConvertUrnsToChannelItems(List urns, CancellationToken cancellationToken)
diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs
index 00b182a..eb72b69 100644
--- a/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs
+++ b/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs
@@ -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
{
+ /// 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;
@@ -56,6 +61,67 @@ public class RecordingController : ControllerBase
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.
///
diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs
index 44d5413..fb21d03 100644
--- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs
+++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs
@@ -47,6 +47,19 @@ public interface IRecordingService
/// List of matching recording entries.
IReadOnlyList GetRecordings(RecordingState? stateFilter = null);
+ ///
+ /// Gets a single recording by ID.
+ ///
+ /// The recording ID.
+ /// The recording entry, or null if not found.
+ RecordingEntry? GetRecording(string recordingId);
+
+ ///
+ /// Gets recordings that have an output file on disk, finished or still in progress.
+ ///
+ /// Playable recordings, newest first.
+ IReadOnlyList GetPlayableRecordings();
+
///
/// Deletes a completed recording (entry and optionally the file).
///
diff --git a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs
index 8c647f8..992a7e9 100644
--- a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs
+++ b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs
@@ -25,6 +25,15 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
///
public class RecordingService : IRecordingService, IDisposable
{
+ /// How long the stream may be missing from the API mid-recording before we treat the broadcast as over.
+ private static readonly TimeSpan StreamLostGracePeriod = TimeSpan.FromMinutes(5);
+
+ /// When a recording has no ValidTo, how long after ValidFrom to keep waiting for the stream.
+ private static readonly TimeSpan NoValidToGiveUpAfter = TimeSpan.FromHours(12);
+
+ /// Longest the scheduler will block waiting for an imminent DVR window start.
+ private static readonly TimeSpan MaxInlineStartWait = TimeSpan.FromSeconds(60);
+
private readonly ILogger _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly IStreamProxyService _proxyService;
@@ -32,7 +41,7 @@ public class RecordingService : IRecordingService, IDisposable
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
private readonly IServerApplicationHost _appHost;
private readonly IMediaEncoder _mediaEncoder;
- private readonly ConcurrentDictionary _activeProcesses = new();
+ private readonly ConcurrentDictionary _activeProcesses = new();
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
private readonly SemaphoreSlim _persistLock = new(1, 1);
private readonly SemaphoreSlim _processLock = new(1, 1);
@@ -266,15 +275,7 @@ public class RecordingService : IRecordingService, IDisposable
}
StopFfmpeg(recordingId);
-
- entry.State = RecordingState.Completed;
- entry.RecordingEndedAt = DateTime.UtcNow;
-
- if (entry.OutputPath != null && File.Exists(entry.OutputPath))
- {
- entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
- }
-
+ CompleteRecording(entry, DateTime.UtcNow);
_ = SaveRecordingsAsync();
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
@@ -298,6 +299,22 @@ public class RecordingService : IRecordingService, IDisposable
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
}
+ ///
+ public RecordingEntry? GetRecording(string recordingId)
+ {
+ return GetRecordings(null).FirstOrDefault(r => r.Id == recordingId);
+ }
+
+ ///
+ public IReadOnlyList GetPlayableRecordings()
+ {
+ // Gate on the file rather than the state: an in-progress or failed recording
+ // still has watchable footage.
+ return GetRecordings(null)
+ .Where(r => !string.IsNullOrEmpty(r.OutputPath) && File.Exists(r.OutputPath))
+ .ToList();
+ }
+
///
public bool DeleteRecording(string recordingId, bool deleteFile)
{
@@ -369,8 +386,26 @@ public class RecordingService : IRecordingService, IDisposable
{
case RecordingState.Scheduled:
case RecordingState.WaitingForStream:
- // Check if it's time to start recording
- if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
+ // Give up once the broadcast window is over, otherwise we'd retry forever
+ var giveUpAt = validToUtc ?? validFromUtc?.Add(NoValidToGiveUpAfter);
+ if (giveUpAt.HasValue && giveUpAt.Value <= now)
+ {
+ if (entry.RecordingStartedAt.HasValue)
+ {
+ _logger.LogInformation("Recording '{Title}' reached end of broadcast window while waiting for stream, completing", entry.Title);
+ CompleteRecording(entry, now);
+ }
+ else
+ {
+ _logger.LogWarning("Stream for '{Title}' never became available (window ended {GiveUpAt}), marking failed", entry.Title, giveUpAt.Value);
+ entry.State = RecordingState.Failed;
+ entry.ErrorMessage = "Stream never became available";
+ entry.RecordingEndedAt = now;
+ }
+
+ changed = true;
+ }
+ else if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
{
_logger.LogInformation(
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
@@ -389,13 +424,7 @@ public class RecordingService : IRecordingService, IDisposable
{
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
StopFfmpeg(entry.Id);
- entry.State = RecordingState.Completed;
- entry.RecordingEndedAt = now;
- if (entry.OutputPath != null && File.Exists(entry.OutputPath))
- {
- entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
- }
-
+ CompleteRecording(entry, now);
changed = true;
}
else if (!_activeProcesses.ContainsKey(entry.Id))
@@ -426,7 +455,7 @@ public class RecordingService : IRecordingService, IDisposable
if (chapter == null)
{
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
- entry.State = RecordingState.WaitingForStream;
+ MarkStreamUnavailable(entry);
return true;
}
@@ -437,10 +466,29 @@ public class RecordingService : IRecordingService, IDisposable
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
- entry.State = RecordingState.WaitingForStream;
+ MarkStreamUnavailable(entry);
return true;
}
+ // The CDN rejects a DVR window whose start= is still in the future (400/404).
+ // Wait briefly if it's imminent, otherwise try again on the next scheduler run.
+ var windowStart = GetDvrWindowStart(streamUrl);
+ if (windowStart.HasValue && windowStart.Value > DateTime.UtcNow)
+ {
+ var wait = windowStart.Value - DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ if (wait > MaxInlineStartWait)
+ {
+ _logger.LogInformation("Stream for '{Title}' starts at {Start}, waiting", entry.Title, windowStart.Value);
+ entry.State = RecordingState.WaitingForStream;
+ return true;
+ }
+
+ _logger.LogInformation("Stream for '{Title}' starts in {Seconds:F0}s, delaying ffmpeg start", entry.Title, wait.TotalSeconds);
+ await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
+ }
+
+ entry.StreamLostAt = null;
+
// Register the stream with the proxy so we can use the proxy URL
var itemId = $"rec_{entry.Id}";
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn);
@@ -449,22 +497,26 @@ public class RecordingService : IRecordingService, IDisposable
// Build proxy URL for ffmpeg (use localhost for local access)
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
- // Build output file path
- var safeTitle = SanitizeFileName(entry.Title);
- var timestamp = DisplayTime.Now().ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
- var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
- entry.OutputPath = outputPath;
+ // Restarts append to the existing .ts so a recording stays one file
+ var outputPath = entry.OutputPath;
+ if (string.IsNullOrEmpty(outputPath) || !outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
+ {
+ var safeTitle = SanitizeFileName(entry.Title);
+ var timestamp = DisplayTime.Now().ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
+ outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.ts");
+ entry.OutputPath = outputPath;
+ }
// Start ffmpeg
StartFfmpeg(entry.Id, proxyUrl, outputPath);
entry.State = RecordingState.Recording;
- entry.RecordingStartedAt = DateTime.UtcNow;
+ entry.RecordingStartedAt ??= DateTime.UtcNow;
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
return true;
}
- catch (Exception ex)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
entry.State = RecordingState.Failed;
@@ -473,6 +525,61 @@ public class RecordingService : IRecordingService, IDisposable
}
}
+ ///
+ /// Handles the API no longer offering a stream. Before recording starts this just means "not live yet";
+ /// once recording has started it means the broadcast ended (possibly before ValidTo).
+ ///
+ private void MarkStreamUnavailable(RecordingEntry entry)
+ {
+ if (!entry.RecordingStartedAt.HasValue)
+ {
+ entry.State = RecordingState.WaitingForStream;
+ return;
+ }
+
+ var now = DateTime.UtcNow;
+ entry.StreamLostAt ??= now;
+
+ if (now - entry.StreamLostAt.Value >= StreamLostGracePeriod)
+ {
+ _logger.LogInformation(
+ "Stream for '{Title}' has been gone since {LostAt}, broadcast has ended - completing recording",
+ entry.Title,
+ entry.StreamLostAt.Value);
+ StopFfmpeg(entry.Id);
+ CompleteRecording(entry, now);
+ return;
+ }
+
+ entry.State = RecordingState.WaitingForStream;
+ }
+
+ private static void CompleteRecording(RecordingEntry entry, DateTime now)
+ {
+ entry.State = RecordingState.Completed;
+ entry.RecordingEndedAt = now;
+ if (entry.OutputPath != null && File.Exists(entry.OutputPath))
+ {
+ entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
+ }
+ }
+
+ ///
+ /// Reads the DVR window start (unix seconds in the start= query parameter) from a stream URL.
+ ///
+ private static DateTime? GetDvrWindowStart(string streamUrl)
+ {
+ if (!Uri.TryCreate(streamUrl, UriKind.Absolute, out var uri))
+ {
+ return null;
+ }
+
+ var start = System.Web.HttpUtility.ParseQueryString(uri.Query)["start"];
+ return long.TryParse(start, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)
+ ? DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime
+ : null;
+ }
+
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
{
var process = new Process
@@ -480,13 +587,16 @@ public class RecordingService : IRecordingService, IDisposable
StartInfo = new ProcessStartInfo
{
FileName = _mediaEncoder.EncoderPath,
- Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
+ // MPEG-TS on stdout: it has no header/index to finalise, so the file is playable while it
+ // grows, and we append it ourselves so restarts continue the same file. -copyts keeps the
+ // broadcast timestamps so an appended restart continues the timeline instead of resetting to 0.
+ Arguments = $"-i \"{inputUrl}\" -c copy -copyts -f mpegts pipe:1",
UseShellExecute = false,
RedirectStandardInput = true,
+ RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
- },
- EnableRaisingEvents = true
+ }
};
process.ErrorDataReceived += (_, args) =>
@@ -497,23 +607,49 @@ public class RecordingService : IRecordingService, IDisposable
}
};
- process.Exited += (_, _) =>
- {
- _logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, process.ExitCode);
- _activeProcesses.TryRemove(recordingId, out _);
- };
-
process.Start();
process.BeginErrorReadLine();
- _activeProcesses[recordingId] = process;
- _logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments);
+ var active = new ActiveRecording(process);
+ _activeProcesses[recordingId] = active;
+ active.Pump = PumpToFileAsync(recordingId, active, outputPath);
+
+ _logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId} -> {OutputPath}: {Args}", process.Id, recordingId, outputPath, process.StartInfo.Arguments);
+ }
+
+ ///
+ /// Appends ffmpeg's stdout to the recording file. The recording counts as active until
+ /// ffmpeg has exited and all of its output is on disk, so a restart never interleaves writes.
+ ///
+ private async Task PumpToFileAsync(string recordingId, ActiveRecording active, string outputPath)
+ {
+ try
+ {
+ var output = new FileStream(outputPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
+ await using (output.ConfigureAwait(false))
+ {
+ await active.Process.StandardOutput.BaseStream.CopyToAsync(output).ConfigureAwait(false);
+ }
+
+ await active.Process.WaitForExitAsync().ConfigureAwait(false);
+ _logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, active.Process.ExitCode);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error writing recording output for {RecordingId}", recordingId);
+ }
+ finally
+ {
+ // Only remove our own entry; a newer process may already be registered
+ _activeProcesses.TryRemove(new KeyValuePair(recordingId, active));
+ }
}
private void StopFfmpeg(string recordingId)
{
- if (_activeProcesses.TryRemove(recordingId, out var process))
+ if (_activeProcesses.TryRemove(recordingId, out var active))
{
+ var process = active.Process;
try
{
if (!process.HasExited)
@@ -529,6 +665,8 @@ public class RecordingService : IRecordingService, IDisposable
}
}
+ // Let the remaining output reach the file before releasing the process
+ active.Pump?.Wait(TimeSpan.FromSeconds(10));
process.Dispose();
}
catch (Exception ex)
@@ -578,4 +716,19 @@ public class RecordingService : IRecordingService, IDisposable
_disposed = true;
}
+
+ ///
+ /// A running ffmpeg process and the task copying its output to disk.
+ ///
+ private sealed class ActiveRecording
+ {
+ public ActiveRecording(Process process)
+ {
+ Process = process;
+ }
+
+ public Process Process { get; }
+
+ public Task? Pump { get; set; }
+ }
}
diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs
index 00b3681..1c21fed 100644
--- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs
+++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs
@@ -132,10 +132,13 @@ public class StreamProxyService : IStreamProxyService
!string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));
// Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON
- // Don't replace a valid token (>5s left) with a new deferred registration
- if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5)
+ // Don't replace a valid token (>5s left) with a new deferred registration.
+ // Livestreams with a URN refresh themselves from the API, so never swap them: an unrelated
+ // registration (e.g. from a library refresh) would point the stream at the wrong CDN path.
+ var selfRefreshing = streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn);
+ if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5 && !selfRefreshing)
{
- var freshStream = FindFreshestStream();
+ var freshStream = FindFreshestStream(streamInfo.Urn);
if (freshStream != null && freshStream.Value.Value.NeedsAuthentication)
{
_logger.LogWarning(
@@ -595,8 +598,9 @@ public class StreamProxyService : IStreamProxyService
///
/// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
///
+ /// When set, only streams for this URN are considered.
/// The freshest stream entry, or null if none found.
- private KeyValuePair? FindFreshestStream()
+ private KeyValuePair? FindFreshestStream(string? urn)
{
var now = DateTime.UtcNow;
@@ -604,6 +608,11 @@ public class StreamProxyService : IStreamProxyService
// or have tokens that aren't expired yet
var candidates = _streamMappings.Where(kvp =>
{
+ if (!string.IsNullOrEmpty(urn) && !string.Equals(kvp.Value.Urn, urn, StringComparison.OrdinalIgnoreCase))
+ {
+ return false; // Different content
+ }
+
if (kvp.Value.NeedsAuthentication)
{
return true; // Fresh deferred registration