554 lines
20 KiB
C#
554 lines
20 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 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 config = Plugin.Instance?.Configuration;
|
|
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
|
|
|
using var apiClient = _apiClientFactory.CreateClient();
|
|
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
|
|
|
if (livestreams == null)
|
|
{
|
|
return Array.Empty<PlayV3TvProgram>();
|
|
}
|
|
|
|
// Filter to only future/current livestreams that aren't blocked
|
|
return livestreams
|
|
.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
|
|
var config = Plugin.Instance?.Configuration;
|
|
var businessUnit = (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,
|
|
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;
|
|
}
|
|
|
|
/// <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" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
|
_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;
|
|
}
|
|
}
|