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