Files
jellyfin-srfPlay/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs
T
dtourolleandClaude Opus 5 390146e8d4
🏗️ Build Plugin / build (push) Successful in 1m43s
🧪 Test Plugin / test (push) Successful in 34s
🚀 Release Plugin / build-and-release (push) Successful in 51s
Nightly Build / nightly-build (push) Failing after 50s
Clean up stale Continue Watching entries
Livestreams and ended broadcasts accumulated in every user's resume row
because Jellyfin saves a playback position for channel items regardless
of whether the position means anything. A livestream has nothing to
return to, so the entry stayed pinned forever.

Two parts:

- PlaybackResumeGuard, an IHostedService on ISessionManager.PlaybackStopped.
  Jellyfin saves the resume point before raising the event, so the guard
  zeroes it afterwards for livestreams. Stops new entries at the source.
- ResumeCleanupService plus a daily 4 AM task (after the 3 AM expiration
  check) for the existing backlog. Clears livestreams, resume points older
  than N days, positions under N seconds and playback past N% of runtime,
  each threshold configurable.

Only plugin-owned items are touched, matched by the SRF provider ID with a
fallback to the owning channel ID so recordings are covered too. Clearing
sets PlaybackPositionTicks to 0 and leaves Played false: nothing is deleted
and the item stays unwatched.

Config page gains the thresholds plus "Clean Up Stale Entries Now" and
"Clear All SRF Play Entries" buttons, backed by MaintenanceController
under RequiresElevation.

Also folds the duplicated urn.Contains("livestream") check in
MediaSourceFactory and RecordingService into UrnHelper.IsLivestreamUrn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:55:41 +02:00

582 lines
21 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Controller;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing sport livestream recordings using ffmpeg.
/// </summary>
public class RecordingService : IRecordingService, IDisposable
{
private readonly ILogger<RecordingService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly IStreamProxyService _proxyService;
private readonly IStreamUrlResolver _streamUrlResolver;
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
private readonly IServerApplicationHost _appHost;
private readonly IMediaEncoder _mediaEncoder;
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
private readonly SemaphoreSlim _persistLock = new(1, 1);
private readonly SemaphoreSlim _processLock = new(1, 1);
private List<RecordingEntry> _recordings = new();
private bool _loaded;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="RecordingService"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="streamUrlResolver">The stream URL resolver.</param>
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
/// <param name="appHost">The application host.</param>
/// <param name="mediaEncoder">The media encoder for ffmpeg path.</param>
public RecordingService(
ILogger<RecordingService> logger,
ISRFApiClientFactory apiClientFactory,
IStreamProxyService proxyService,
IStreamUrlResolver streamUrlResolver,
IMediaCompositionFetcher mediaCompositionFetcher,
IServerApplicationHost appHost,
IMediaEncoder mediaEncoder)
{
_logger = logger;
_apiClientFactory = apiClientFactory;
_proxyService = proxyService;
_streamUrlResolver = streamUrlResolver;
_mediaCompositionFetcher = mediaCompositionFetcher;
_appHost = appHost;
_mediaEncoder = mediaEncoder;
}
private string GetDataFilePath()
{
var dataPath = Plugin.Instance?.DataFolderPath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jellyfin", "plugins", "SRFPlay");
Directory.CreateDirectory(dataPath);
return Path.Combine(dataPath, "recordings.json");
}
private string GetRecordingOutputPath()
{
var config = Plugin.Instance?.Configuration;
var path = config?.RecordingOutputPath;
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings");
}
Directory.CreateDirectory(path);
return path;
}
private string GetServerBaseUrl()
{
var config = Plugin.Instance?.Configuration;
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
{
return config.PublicServerUrl.TrimEnd('/');
}
// For local ffmpeg access, use localhost directly
return "http://localhost:8096";
}
private async Task LoadRecordingsAsync()
{
if (_loaded)
{
return;
}
var filePath = GetDataFilePath();
if (File.Exists(filePath))
{
try
{
var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
_recordings = JsonSerializer.Deserialize<List<RecordingEntry>>(json) ?? new List<RecordingEntry>();
_logger.LogInformation("Loaded {Count} recording entries from {Path}", _recordings.Count, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load recordings from {Path}", filePath);
_recordings = new List<RecordingEntry>();
}
}
_loaded = true;
}
private async Task SaveRecordingsAsync()
{
await _persistLock.WaitAsync().ConfigureAwait(false);
try
{
var filePath = GetDataFilePath();
var json = JsonSerializer.Serialize(_recordings, _jsonOptions);
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save recordings");
}
finally
{
_persistLock.Release();
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
{
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
using var apiClient = _apiClientFactory.CreateClient();
// Aggregate sport livestreams across every business unit so the recordings
// page shows events from all languages.
var all = new List<PlayV3TvProgram>();
foreach (var unit in units)
{
var businessUnit = unit.ToString().ToLowerInvariant();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (livestreams != null)
{
all.AddRange(livestreams);
}
}
// Filter to only future/current livestreams that aren't blocked
return all
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
.OrderBy(ls => ls.ValidFrom)
.ToList();
}
/// <inheritdoc />
public async Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken)
{
await LoadRecordingsAsync().ConfigureAwait(false);
// Check if already scheduled
var existing = _recordings.FirstOrDefault(r => r.Urn == urn && r.State is RecordingState.Scheduled or RecordingState.WaitingForStream or RecordingState.Recording);
if (existing != null)
{
_logger.LogInformation("Recording already exists for URN {Urn} in state {State}", urn, existing.State);
return existing;
}
// Fetch metadata for the URN. The unit is encoded in the URN itself, so use that to
// query the right schedule regardless of which units are enabled.
var config = Plugin.Instance?.Configuration;
var businessUnit = ParseBusinessUnitFromUrn(urn)
?? (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
using var apiClient = _apiClientFactory.CreateClient();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
var program = livestreams?.FirstOrDefault(ls => ls.Urn == urn);
var entry = new RecordingEntry
{
Id = Guid.NewGuid().ToString("N"),
Urn = urn,
BusinessUnit = ParseBusinessUnitFromUrn(urn) ?? businessUnit,
Title = program?.Title ?? urn,
Description = program?.Lead ?? program?.Description,
ImageUrl = program?.ImageUrl,
ValidFrom = program?.ValidFrom,
ValidTo = program?.ValidTo,
State = RecordingState.Scheduled,
CreatedAt = DateTime.UtcNow
};
_recordings.Add(entry);
await SaveRecordingsAsync().ConfigureAwait(false);
_logger.LogInformation("Scheduled recording for '{Title}' (URN: {Urn}, starts: {ValidFrom})", entry.Title, urn, entry.ValidFrom);
return entry;
}
/// <summary>
/// Extracts the business unit from an SRF URN of the form "urn:&lt;bu&gt;:&lt;type&gt;:...".
/// </summary>
/// <param name="urn">The URN.</param>
/// <returns>The lowercase business unit, or null if it cannot be determined.</returns>
private static string? ParseBusinessUnitFromUrn(string urn)
{
if (string.IsNullOrEmpty(urn))
{
return null;
}
var parts = urn.Split(':');
return parts.Length >= 2 && !string.IsNullOrEmpty(parts[1])
? parts[1].ToLowerInvariant()
: null;
}
/// <inheritdoc />
public bool CancelRecording(string recordingId)
{
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
if (entry == null)
{
return false;
}
if (entry.State == RecordingState.Recording)
{
StopFfmpeg(recordingId);
}
entry.State = RecordingState.Cancelled;
entry.RecordingEndedAt = DateTime.UtcNow;
_ = SaveRecordingsAsync();
_logger.LogInformation("Cancelled recording '{Title}' ({Id})", entry.Title, recordingId);
return true;
}
/// <inheritdoc />
public bool StopRecording(string recordingId)
{
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId && r.State == RecordingState.Recording);
if (entry == null)
{
return false;
}
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;
}
_ = SaveRecordingsAsync();
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
return true;
}
/// <inheritdoc />
public IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter)
{
// Ensure loaded synchronously for simple reads
if (!_loaded)
{
LoadRecordingsAsync().GetAwaiter().GetResult();
}
if (stateFilter.HasValue)
{
return _recordings.Where(r => r.State == stateFilter.Value).OrderByDescending(r => r.CreatedAt).ToList();
}
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
}
/// <inheritdoc />
public bool DeleteRecording(string recordingId, bool deleteFile)
{
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
if (entry == null)
{
return false;
}
if (entry.State == RecordingState.Recording)
{
StopFfmpeg(recordingId);
}
if (deleteFile && !string.IsNullOrEmpty(entry.OutputPath) && File.Exists(entry.OutputPath))
{
try
{
File.Delete(entry.OutputPath);
_logger.LogInformation("Deleted recording file: {Path}", entry.OutputPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete recording file: {Path}", entry.OutputPath);
}
}
_recordings.Remove(entry);
_ = SaveRecordingsAsync();
_logger.LogInformation("Deleted recording entry '{Title}' ({Id})", entry.Title, recordingId);
return true;
}
/// <inheritdoc />
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
{
// Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes
if (!await _processLock.WaitAsync(0, CancellationToken.None).ConfigureAwait(false))
{
_logger.LogDebug("ProcessRecordingsAsync already running, skipping");
return;
}
try
{
await ProcessRecordingsCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_processLock.Release();
}
}
private async Task ProcessRecordingsCoreAsync(CancellationToken cancellationToken)
{
await LoadRecordingsAsync().ConfigureAwait(false);
var now = DateTime.UtcNow;
var changed = false;
foreach (var entry in _recordings.ToList())
{
// Normalize ValidFrom/ValidTo to UTC for correct comparison
var validFromUtc = entry.ValidFrom.HasValue ? entry.ValidFrom.Value.ToUniversalTime() : (DateTime?)null;
var validToUtc = entry.ValidTo.HasValue ? entry.ValidTo.Value.ToUniversalTime() : (DateTime?)null;
switch (entry.State)
{
case RecordingState.Scheduled:
case RecordingState.WaitingForStream:
// Check if it's time to start recording
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
{
_logger.LogInformation(
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
entry.Title,
entry.ValidFrom,
validFromUtc,
now);
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
}
break;
case RecordingState.Recording:
// Check if recording should stop (ValidTo reached or process died)
if (validToUtc.HasValue && validToUtc.Value <= now)
{
_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;
}
changed = true;
}
else if (!_activeProcesses.ContainsKey(entry.Id))
{
// ffmpeg process died unexpectedly — try to restart
_logger.LogWarning("ffmpeg process for '{Title}' is no longer running, attempting restart", entry.Title);
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
}
break;
}
}
if (changed)
{
await SaveRecordingsAsync().ConfigureAwait(false);
}
}
private async Task<bool> TryStartRecordingAsync(RecordingEntry entry, CancellationToken cancellationToken)
{
try
{
// Fetch the media composition to get the stream URL
var mediaComposition = await _mediaCompositionFetcher.GetMediaCompositionAsync(entry.Urn, cacheDurationOverride: 2, cancellationToken: cancellationToken).ConfigureAwait(false);
var chapter = mediaComposition?.ChapterList is { Count: > 0 } list ? list[0] : null;
if (chapter == null)
{
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
entry.State = RecordingState.WaitingForStream;
return true;
}
var config = Plugin.Instance?.Configuration;
var quality = config?.QualityPreference ?? Configuration.QualityPreference.Auto;
var streamUrl = _streamUrlResolver.GetStreamUrl(chapter, quality);
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
entry.State = RecordingState.WaitingForStream;
return true;
}
// 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);
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
// 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 = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
entry.OutputPath = outputPath;
// Start ffmpeg
StartFfmpeg(entry.Id, proxyUrl, outputPath);
entry.State = RecordingState.Recording;
entry.RecordingStartedAt = DateTime.UtcNow;
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
entry.State = RecordingState.Failed;
entry.ErrorMessage = ex.Message;
return true;
}
}
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _mediaEncoder.EncoderPath,
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
process.ErrorDataReceived += (_, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
_logger.LogDebug("ffmpeg [{RecordingId}]: {Data}", recordingId, args.Data);
}
};
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);
}
private void StopFfmpeg(string recordingId)
{
if (_activeProcesses.TryRemove(recordingId, out var process))
{
try
{
if (!process.HasExited)
{
// Send 'q' to ffmpeg stdin for graceful shutdown
process.StandardInput.Write("q");
process.StandardInput.Flush();
if (!process.WaitForExit(10000))
{
_logger.LogWarning("ffmpeg did not exit gracefully for {RecordingId}, killing", recordingId);
process.Kill(true);
}
}
process.Dispose();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error stopping ffmpeg for recording {RecordingId}", recordingId);
}
}
}
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var sanitized = string.Join("_", name.Split(invalid, StringSplitOptions.RemoveEmptyEntries));
// Also replace spaces and other problematic chars
sanitized = Regex.Replace(sanitized, @"[\s]+", "_");
return sanitized.Length > 100 ? sanitized[..100] : sanitized;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases resources.
/// </summary>
/// <param name="disposing">True to release managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
foreach (var kvp in _activeProcesses)
{
StopFfmpeg(kvp.Key);
}
_persistLock.Dispose();
_processLock.Dispose();
}
_disposed = true;
}
}