Fix recordings dying mid-stream; record to growing .ts with live buffer playback
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:
@@ -28,6 +28,8 @@ namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||
/// </summary>
|
||||
public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||
{
|
||||
private const string InProgressPrefix = "● REC · ";
|
||||
|
||||
private readonly ILogger<SrgChannelBase> _logger;
|
||||
private readonly IContentRefreshService _contentRefreshService;
|
||||
private readonly IStreamUrlResolver _streamResolver;
|
||||
@@ -320,15 +322,10 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||
{
|
||||
var items = new List<ChannelItemInfo>();
|
||||
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<MediaSourceInfo> { 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<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user