Selaa lähdekoodia

Add IRecordingsManager service

Patrick Barron 1 vuosi sitten
vanhempi
sitoutus
0370167b8d

+ 1 - 1
Emby.Server.Implementations/ApplicationHost.cs

@@ -630,7 +630,7 @@ namespace Emby.Server.Implementations
             BaseItem.FileSystem = Resolve<IFileSystem>();
             BaseItem.UserDataManager = Resolve<IUserDataManager>();
             BaseItem.ChannelManager = Resolve<IChannelManager>();
-            Video.LiveTvManager = Resolve<ILiveTvManager>();
+            Video.RecordingsManager = Resolve<IRecordingsManager>();
             Folder.UserViewManager = Resolve<IUserViewManager>();
             UserView.TVSeriesManager = Resolve<ITVSeriesManager>();
             UserView.CollectionManager = Resolve<ICollectionManager>();

+ 5 - 3
Emby.Server.Implementations/Dto/DtoService.cs

@@ -47,6 +47,7 @@ namespace Emby.Server.Implementations.Dto
 
         private readonly IImageProcessor _imageProcessor;
         private readonly IProviderManager _providerManager;
+        private readonly IRecordingsManager _recordingsManager;
 
         private readonly IApplicationHost _appHost;
         private readonly IMediaSourceManager _mediaSourceManager;
@@ -62,6 +63,7 @@ namespace Emby.Server.Implementations.Dto
             IItemRepository itemRepo,
             IImageProcessor imageProcessor,
             IProviderManager providerManager,
+            IRecordingsManager recordingsManager,
             IApplicationHost appHost,
             IMediaSourceManager mediaSourceManager,
             Lazy<ILiveTvManager> livetvManagerFactory,
@@ -74,6 +76,7 @@ namespace Emby.Server.Implementations.Dto
             _itemRepo = itemRepo;
             _imageProcessor = imageProcessor;
             _providerManager = providerManager;
+            _recordingsManager = recordingsManager;
             _appHost = appHost;
             _mediaSourceManager = mediaSourceManager;
             _livetvManagerFactory = livetvManagerFactory;
@@ -256,8 +259,7 @@ namespace Emby.Server.Implementations.Dto
                 dto.Etag = item.GetEtag(user);
             }
 
-            var liveTvManager = LivetvManager;
-            var activeRecording = liveTvManager.GetActiveRecordingInfo(item.Path);
+            var activeRecording = _recordingsManager.GetActiveRecordingInfo(item.Path);
             if (activeRecording is not null)
             {
                 dto.Type = BaseItemKind.Recording;
@@ -270,7 +272,7 @@ namespace Emby.Server.Implementations.Dto
                     dto.Name = dto.SeriesName;
                 }
 
-                liveTvManager.AddInfoToRecordingDto(item, dto, activeRecording, user);
+                LivetvManager.AddInfoToRecordingDto(item, dto, activeRecording, user);
             }
 
             return dto;

+ 5 - 2
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -46,6 +46,7 @@ public class LiveTvController : BaseJellyfinApiController
     private readonly IGuideManager _guideManager;
     private readonly ITunerHostManager _tunerHostManager;
     private readonly IListingsManager _listingsManager;
+    private readonly IRecordingsManager _recordingsManager;
     private readonly IUserManager _userManager;
     private readonly IHttpClientFactory _httpClientFactory;
     private readonly ILibraryManager _libraryManager;
@@ -61,6 +62,7 @@ public class LiveTvController : BaseJellyfinApiController
     /// <param name="guideManager">Instance of the <see cref="IGuideManager"/> interface.</param>
     /// <param name="tunerHostManager">Instance of the <see cref="ITunerHostManager"/> interface.</param>
     /// <param name="listingsManager">Instance of the <see cref="IListingsManager"/> interface.</param>
+    /// <param name="recordingsManager">Instance of the <see cref="IRecordingsManager"/> interface.</param>
     /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
     /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
     /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
@@ -73,6 +75,7 @@ public class LiveTvController : BaseJellyfinApiController
         IGuideManager guideManager,
         ITunerHostManager tunerHostManager,
         IListingsManager listingsManager,
+        IRecordingsManager recordingsManager,
         IUserManager userManager,
         IHttpClientFactory httpClientFactory,
         ILibraryManager libraryManager,
@@ -85,6 +88,7 @@ public class LiveTvController : BaseJellyfinApiController
         _guideManager = guideManager;
         _tunerHostManager = tunerHostManager;
         _listingsManager = listingsManager;
+        _recordingsManager = recordingsManager;
         _userManager = userManager;
         _httpClientFactory = httpClientFactory;
         _libraryManager = libraryManager;
@@ -1140,8 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController
     [ProducesVideoFile]
     public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId)
     {
-        var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId);
-
+        var path = _recordingsManager.GetActiveRecordingPath(recordingId);
         if (string.IsNullOrWhiteSpace(path))
         {
             return NotFound();

+ 2 - 2
MediaBrowser.Controller/Entities/Video.cs

@@ -171,7 +171,7 @@ namespace MediaBrowser.Controller.Entities
         [JsonIgnore]
         public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0;
 
-        public static ILiveTvManager LiveTvManager { get; set; }
+        public static IRecordingsManager RecordingsManager { get; set; }
 
         [JsonIgnore]
         public override SourceType SourceType
@@ -334,7 +334,7 @@ namespace MediaBrowser.Controller.Entities
 
         protected override bool IsActiveRecording()
         {
-            return LiveTvManager.GetActiveRecordingInfo(Path) is not null;
+            return RecordingsManager.GetActiveRecordingInfo(Path) is not null;
         }
 
         public override bool CanDelete()

+ 0 - 4
MediaBrowser.Controller/LiveTv/ILiveTvManager.cs

@@ -245,10 +245,6 @@ namespace MediaBrowser.Controller.LiveTv
         /// <param name="user">The user.</param>
         void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user);
 
-        string GetEmbyTvActiveRecordingPath(string id);
-
-        ActiveRecordingInfo GetActiveRecordingInfo(string path);
-
         void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null);
 
         Task<BaseItem[]> GetRecordingFoldersAsync(User user);

+ 55 - 0
MediaBrowser.Controller/LiveTv/IRecordingsManager.cs

@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Controller.LiveTv;
+
+/// <summary>
+/// Service responsible for managing LiveTV recordings.
+/// </summary>
+public interface IRecordingsManager
+{
+    /// <summary>
+    /// Gets the path for the provided timer id.
+    /// </summary>
+    /// <param name="id">The timer id.</param>
+    /// <returns>The recording path, or <c>null</c> if none exists.</returns>
+    string? GetActiveRecordingPath(string id);
+
+    /// <summary>
+    /// Gets the information for an active recording.
+    /// </summary>
+    /// <param name="path">The recording path.</param>
+    /// <returns>The <see cref="ActiveRecordingInfo"/>, or <c>null</c> if none exists.</returns>
+    ActiveRecordingInfo? GetActiveRecordingInfo(string path);
+
+    /// <summary>
+    /// Gets the recording folders.
+    /// </summary>
+    /// <returns>The <see cref="VirtualFolderInfo"/> for each recording folder.</returns>
+    IEnumerable<VirtualFolderInfo> GetRecordingFolders();
+
+    /// <summary>
+    /// Ensures that the recording folders all exist, and removes unused folders.
+    /// </summary>
+    /// <returns>Task.</returns>
+    Task CreateRecordingFolders();
+
+    /// <summary>
+    /// Cancels the recording with the provided timer id, if one is active.
+    /// </summary>
+    /// <param name="timerId">The timer id.</param>
+    /// <param name="timer">The timer.</param>
+    void CancelRecording(string timerId, TimerInfo? timer);
+
+    /// <summary>
+    /// Records a stream.
+    /// </summary>
+    /// <param name="recordingInfo">The recording info.</param>
+    /// <param name="channel">The channel associated with the recording timer.</param>
+    /// <param name="recordingEndDate">The time to stop recording.</param>
+    /// <returns>Task representing the recording process.</returns>
+    Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate);
+}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 34 - 810
src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs


+ 13 - 7
src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs

@@ -1,7 +1,6 @@
-using System.Collections.Generic;
-using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.LiveTv.Timers;
 using MediaBrowser.Controller.LiveTv;
 using Microsoft.Extensions.Hosting;
 
@@ -12,19 +11,26 @@ namespace Jellyfin.LiveTv.EmbyTV;
 /// </summary>
 public sealed class LiveTvHost : IHostedService
 {
-    private readonly EmbyTV _service;
+    private readonly IRecordingsManager _recordingsManager;
+    private readonly TimerManager _timerManager;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="LiveTvHost"/> class.
     /// </summary>
-    /// <param name="services">The available <see cref="ILiveTvService"/>s.</param>
-    public LiveTvHost(IEnumerable<ILiveTvService> services)
+    /// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
+    /// <param name="timerManager">The <see cref="TimerManager"/>.</param>
+    public LiveTvHost(IRecordingsManager recordingsManager, TimerManager timerManager)
     {
-        _service = services.OfType<EmbyTV>().First();
+        _recordingsManager = recordingsManager;
+        _timerManager = timerManager;
     }
 
     /// <inheritdoc />
-    public Task StartAsync(CancellationToken cancellationToken) => _service.Start();
+    public Task StartAsync(CancellationToken cancellationToken)
+    {
+        _timerManager.RestartTimers();
+        return _recordingsManager.CreateRecordingFolders();
+    }
 
     /// <inheritdoc />
     public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;

+ 2 - 0
src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs

@@ -28,12 +28,14 @@ public static class LiveTvServiceCollectionExtensions
         services.AddSingleton<TimerManager>();
         services.AddSingleton<SeriesTimerManager>();
         services.AddSingleton<RecordingsMetadataManager>();
+
         services.AddSingleton<ILiveTvManager, LiveTvManager>();
         services.AddSingleton<IChannelManager, ChannelManager>();
         services.AddSingleton<IStreamHelper, StreamHelper>();
         services.AddSingleton<ITunerHostManager, TunerHostManager>();
         services.AddSingleton<IListingsManager, ListingsManager>();
         services.AddSingleton<IGuideManager, GuideManager>();
+        services.AddSingleton<IRecordingsManager, RecordingsManager>();
 
         services.AddSingleton<ILiveTvService, EmbyTV.EmbyTV>();
         services.AddSingleton<ITunerHost, HdHomerunHost>();

+ 5 - 1
src/Jellyfin.LiveTv/Guide/GuideManager.cs

@@ -34,6 +34,7 @@ public class GuideManager : IGuideManager
     private readonly ILibraryManager _libraryManager;
     private readonly ILiveTvManager _liveTvManager;
     private readonly ITunerHostManager _tunerHostManager;
+    private readonly IRecordingsManager _recordingsManager;
     private readonly LiveTvDtoService _tvDtoService;
 
     /// <summary>
@@ -46,6 +47,7 @@ public class GuideManager : IGuideManager
     /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
     /// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
     /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
+    /// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
     /// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
     public GuideManager(
         ILogger<GuideManager> logger,
@@ -55,6 +57,7 @@ public class GuideManager : IGuideManager
         ILibraryManager libraryManager,
         ILiveTvManager liveTvManager,
         ITunerHostManager tunerHostManager,
+        IRecordingsManager recordingsManager,
         LiveTvDtoService tvDtoService)
     {
         _logger = logger;
@@ -64,6 +67,7 @@ public class GuideManager : IGuideManager
         _libraryManager = libraryManager;
         _liveTvManager = liveTvManager;
         _tunerHostManager = tunerHostManager;
+        _recordingsManager = recordingsManager;
         _tvDtoService = tvDtoService;
     }
 
@@ -85,7 +89,7 @@ public class GuideManager : IGuideManager
     {
         ArgumentNullException.ThrowIfNull(progress);
 
-        await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false);
+        await _recordingsManager.CreateRecordingFolders().ConfigureAwait(false);
 
         await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
 

+ 7 - 14
src/Jellyfin.LiveTv/LiveTvManager.cs

@@ -43,6 +43,7 @@ namespace Jellyfin.LiveTv
         private readonly ILibraryManager _libraryManager;
         private readonly ILocalizationManager _localization;
         private readonly IChannelManager _channelManager;
+        private readonly IRecordingsManager _recordingsManager;
         private readonly LiveTvDtoService _tvDtoService;
         private readonly ILiveTvService[] _services;
 
@@ -55,6 +56,7 @@ namespace Jellyfin.LiveTv
             ILibraryManager libraryManager,
             ILocalizationManager localization,
             IChannelManager channelManager,
+            IRecordingsManager recordingsManager,
             LiveTvDtoService liveTvDtoService,
             IEnumerable<ILiveTvService> services)
         {
@@ -67,6 +69,7 @@ namespace Jellyfin.LiveTv
             _userDataManager = userDataManager;
             _channelManager = channelManager;
             _tvDtoService = liveTvDtoService;
+            _recordingsManager = recordingsManager;
             _services = services.ToArray();
 
             var defaultService = _services.OfType<EmbyTV.EmbyTV>().First();
@@ -88,11 +91,6 @@ namespace Jellyfin.LiveTv
         /// <value>The services.</value>
         public IReadOnlyList<ILiveTvService> Services => _services;
 
-        public string GetEmbyTvActiveRecordingPath(string id)
-        {
-            return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id);
-        }
-
         private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs<string> e)
         {
             var timerId = e.Argument;
@@ -765,18 +763,13 @@ namespace Jellyfin.LiveTv
             return AddRecordingInfo(programTuples, CancellationToken.None);
         }
 
-        public ActiveRecordingInfo GetActiveRecordingInfo(string path)
-        {
-            return EmbyTV.EmbyTV.Current.GetActiveRecordingInfo(path);
-        }
-
         public void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null)
         {
-            var service = EmbyTV.EmbyTV.Current;
-
             var info = activeRecordingInfo.Timer;
 
-            var channel = string.IsNullOrWhiteSpace(info.ChannelId) ? null : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(service.Name, info.ChannelId));
+            var channel = string.IsNullOrWhiteSpace(info.ChannelId)
+                ? null
+                : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(EmbyTV.EmbyTV.ServiceName, info.ChannelId));
 
             dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId)
                 ? null
@@ -1461,7 +1454,7 @@ namespace Jellyfin.LiveTv
 
         private async Task<BaseItem[]> GetRecordingFoldersAsync(User user, bool refreshChannels)
         {
-            var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders()
+            var folders = _recordingsManager.GetRecordingFolders()
                 .SelectMany(i => i.Locations)
                 .Distinct(StringComparer.OrdinalIgnoreCase)
                 .Select(i => _libraryManager.FindByPath(i, true))

+ 4 - 2
src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs

@@ -24,13 +24,15 @@ namespace Jellyfin.LiveTv
         private const char StreamIdDelimiter = '_';
 
         private readonly ILiveTvManager _liveTvManager;
+        private readonly IRecordingsManager _recordingsManager;
         private readonly ILogger<LiveTvMediaSourceProvider> _logger;
         private readonly IMediaSourceManager _mediaSourceManager;
         private readonly IServerApplicationHost _appHost;
 
-        public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost)
+        public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IRecordingsManager recordingsManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost)
         {
             _liveTvManager = liveTvManager;
+            _recordingsManager = recordingsManager;
             _logger = logger;
             _mediaSourceManager = mediaSourceManager;
             _appHost = appHost;
@@ -40,7 +42,7 @@ namespace Jellyfin.LiveTv
         {
             if (item.SourceType == SourceType.LiveTV)
             {
-                var activeRecordingInfo = _liveTvManager.GetActiveRecordingInfo(item.Path);
+                var activeRecordingInfo = _recordingsManager.GetActiveRecordingInfo(item.Path);
 
                 if (string.IsNullOrEmpty(item.Path) || activeRecordingInfo is not null)
                 {

+ 849 - 0
src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs

@@ -0,0 +1,849 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using AsyncKeyedLock;
+using Jellyfin.Data.Enums;
+using Jellyfin.LiveTv.Configuration;
+using Jellyfin.LiveTv.EmbyTV;
+using Jellyfin.LiveTv.IO;
+using Jellyfin.LiveTv.Timers;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Providers;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.Recordings;
+
+/// <inheritdoc cref="IRecordingsManager" />
+public sealed class RecordingsManager : IRecordingsManager, IDisposable
+{
+    private readonly ILogger<RecordingsManager> _logger;
+    private readonly IServerConfigurationManager _config;
+    private readonly IHttpClientFactory _httpClientFactory;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILibraryMonitor _libraryMonitor;
+    private readonly IProviderManager _providerManager;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IStreamHelper _streamHelper;
+    private readonly TimerManager _timerManager;
+    private readonly SeriesTimerManager _seriesTimerManager;
+    private readonly RecordingsMetadataManager _recordingsMetadataManager;
+
+    private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings = new(StringComparer.OrdinalIgnoreCase);
+    private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new();
+    private bool _disposed;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="RecordingsManager"/> class.
+    /// </summary>
+    /// <param name="logger">The <see cref="ILogger"/>.</param>
+    /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param>
+    /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
+    /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
+    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
+    /// <param name="libraryMonitor">The <see cref="ILibraryMonitor"/>.</param>
+    /// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
+    /// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
+    /// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
+    /// <param name="streamHelper">The <see cref="IStreamHelper"/>.</param>
+    /// <param name="timerManager">The <see cref="TimerManager"/>.</param>
+    /// <param name="seriesTimerManager">The <see cref="SeriesTimerManager"/>.</param>
+    /// <param name="recordingsMetadataManager">The <see cref="RecordingsMetadataManager"/>.</param>
+    public RecordingsManager(
+        ILogger<RecordingsManager> logger,
+        IServerConfigurationManager config,
+        IHttpClientFactory httpClientFactory,
+        IFileSystem fileSystem,
+        ILibraryManager libraryManager,
+        ILibraryMonitor libraryMonitor,
+        IProviderManager providerManager,
+        IMediaEncoder mediaEncoder,
+        IMediaSourceManager mediaSourceManager,
+        IStreamHelper streamHelper,
+        TimerManager timerManager,
+        SeriesTimerManager seriesTimerManager,
+        RecordingsMetadataManager recordingsMetadataManager)
+    {
+        _logger = logger;
+        _config = config;
+        _httpClientFactory = httpClientFactory;
+        _fileSystem = fileSystem;
+        _libraryManager = libraryManager;
+        _libraryMonitor = libraryMonitor;
+        _providerManager = providerManager;
+        _mediaEncoder = mediaEncoder;
+        _mediaSourceManager = mediaSourceManager;
+        _streamHelper = streamHelper;
+        _timerManager = timerManager;
+        _seriesTimerManager = seriesTimerManager;
+        _recordingsMetadataManager = recordingsMetadataManager;
+
+        _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
+    }
+
+    private string DefaultRecordingPath
+    {
+        get
+        {
+            var path = _config.GetLiveTvConfiguration().RecordingPath;
+
+            return string.IsNullOrWhiteSpace(path)
+                ? Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv", "recordings")
+                : path;
+        }
+    }
+
+    /// <inheritdoc />
+    public string? GetActiveRecordingPath(string id)
+        => _activeRecordings.GetValueOrDefault(id)?.Path;
+
+    /// <inheritdoc />
+    public ActiveRecordingInfo? GetActiveRecordingInfo(string path)
+    {
+        if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty)
+        {
+            return null;
+        }
+
+        foreach (var (_, recordingInfo) in _activeRecordings)
+        {
+            if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal)
+                && !recordingInfo.CancellationTokenSource.IsCancellationRequested)
+            {
+                return recordingInfo.Timer.Status == RecordingStatus.InProgress ? recordingInfo : null;
+            }
+        }
+
+        return null;
+    }
+
+    /// <inheritdoc />
+    public IEnumerable<VirtualFolderInfo> GetRecordingFolders()
+    {
+        if (Directory.Exists(DefaultRecordingPath))
+        {
+            yield return new VirtualFolderInfo
+            {
+                Locations = [DefaultRecordingPath],
+                Name = "Recordings"
+            };
+        }
+
+        var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath;
+        if (!string.IsNullOrWhiteSpace(customPath)
+            && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
+            && Directory.Exists(customPath))
+        {
+            yield return new VirtualFolderInfo
+            {
+                Locations = [customPath],
+                Name = "Recorded Movies",
+                CollectionType = CollectionTypeOptions.Movies
+            };
+        }
+
+        customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath;
+        if (!string.IsNullOrWhiteSpace(customPath)
+            && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
+            && Directory.Exists(customPath))
+        {
+            yield return new VirtualFolderInfo
+            {
+                Locations = [customPath],
+                Name = "Recorded Shows",
+                CollectionType = CollectionTypeOptions.TvShows
+            };
+        }
+    }
+
+    /// <inheritdoc />
+    public async Task CreateRecordingFolders()
+    {
+        try
+        {
+            var recordingFolders = GetRecordingFolders().ToArray();
+            var virtualFolders = _libraryManager.GetVirtualFolders();
+
+            var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList();
+
+            var pathsAdded = new List<string>();
+
+            foreach (var recordingFolder in recordingFolders)
+            {
+                var pathsToCreate = recordingFolder.Locations
+                    .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i)))
+                    .ToList();
+
+                if (pathsToCreate.Count == 0)
+                {
+                    continue;
+                }
+
+                var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray();
+                var libraryOptions = new LibraryOptions
+                {
+                    PathInfos = mediaPathInfos
+                };
+
+                try
+                {
+                    await _libraryManager
+                        .AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true)
+                        .ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error creating virtual folder");
+                }
+
+                pathsAdded.AddRange(pathsToCreate);
+            }
+
+            var config = _config.GetLiveTvConfiguration();
+
+            var pathsToRemove = config.MediaLocationsCreated
+                .Except(recordingFolders.SelectMany(i => i.Locations))
+                .ToList();
+
+            if (pathsAdded.Count > 0 || pathsToRemove.Count > 0)
+            {
+                pathsAdded.InsertRange(0, config.MediaLocationsCreated);
+                config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+                _config.SaveConfiguration("livetv", config);
+            }
+
+            foreach (var path in pathsToRemove)
+            {
+                await RemovePathFromLibraryAsync(path).ConfigureAwait(false);
+            }
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error creating recording folders");
+        }
+    }
+
+    private async Task RemovePathFromLibraryAsync(string path)
+    {
+        _logger.LogDebug("Removing path from library: {0}", path);
+
+        var requiresRefresh = false;
+        var virtualFolders = _libraryManager.GetVirtualFolders();
+
+        foreach (var virtualFolder in virtualFolders)
+        {
+            if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase))
+            {
+                continue;
+            }
+
+            if (virtualFolder.Locations.Length == 1)
+            {
+                try
+                {
+                    await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error removing virtual folder");
+                }
+            }
+            else
+            {
+                try
+                {
+                    _libraryManager.RemoveMediaPath(virtualFolder.Name, path);
+                    requiresRefresh = true;
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error removing media path");
+                }
+            }
+        }
+
+        if (requiresRefresh)
+        {
+            await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+        }
+    }
+
+    /// <inheritdoc />
+    public void CancelRecording(string timerId, TimerInfo? timer)
+    {
+        if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo))
+        {
+            activeRecordingInfo.Timer = timer;
+            activeRecordingInfo.CancellationTokenSource.Cancel();
+        }
+    }
+
+    /// <inheritdoc />
+    public async Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate)
+    {
+        ArgumentNullException.ThrowIfNull(recordingInfo);
+        ArgumentNullException.ThrowIfNull(channel);
+
+        var timer = recordingInfo.Timer;
+        var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
+        var recordingPath = GetRecordingPath(timer, remoteMetadata, out var seriesPath);
+
+        string? liveStreamId = null;
+        RecordingStatus recordingStatus;
+        try
+        {
+            var allMediaSources = await _mediaSourceManager
+                .GetPlaybackMediaSources(channel, null, true, false, CancellationToken.None).ConfigureAwait(false);
+
+            var mediaStreamInfo = allMediaSources[0];
+            IDirectStreamProvider? directStreamProvider = null;
+            if (mediaStreamInfo.RequiresOpening)
+            {
+                var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal(
+                    new LiveStreamRequest
+                    {
+                        ItemId = channel.Id,
+                        OpenToken = mediaStreamInfo.OpenToken
+                    },
+                    CancellationToken.None).ConfigureAwait(false);
+
+                mediaStreamInfo = liveStreamResponse.Item1.MediaSource;
+                liveStreamId = mediaStreamInfo.LiveStreamId;
+                directStreamProvider = liveStreamResponse.Item2;
+            }
+
+            using var recorder = GetRecorder(mediaStreamInfo);
+
+            recordingPath = recorder.GetOutputPath(mediaStreamInfo, recordingPath);
+            recordingPath = EnsureFileUnique(recordingPath, timer.Id);
+
+            _libraryMonitor.ReportFileSystemChangeBeginning(recordingPath);
+
+            var duration = recordingEndDate - DateTime.UtcNow;
+
+            _logger.LogInformation("Beginning recording. Will record for {Duration} minutes.", duration.TotalMinutes);
+            _logger.LogInformation("Writing file to: {Path}", recordingPath);
+
+            async void OnStarted()
+            {
+                recordingInfo.Path = recordingPath;
+                _activeRecordings.TryAdd(timer.Id, recordingInfo);
+
+                timer.Status = RecordingStatus.InProgress;
+                _timerManager.AddOrUpdate(timer, false);
+
+                await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordingPath, seriesPath).ConfigureAwait(false);
+                await CreateRecordingFolders().ConfigureAwait(false);
+
+                TriggerRefresh(recordingPath);
+                await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
+            }
+
+            await recorder.Record(
+                directStreamProvider,
+                mediaStreamInfo,
+                recordingPath,
+                duration,
+                OnStarted,
+                recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
+
+            recordingStatus = RecordingStatus.Completed;
+            _logger.LogInformation("Recording completed: {RecordPath}", recordingPath);
+        }
+        catch (OperationCanceledException)
+        {
+            _logger.LogInformation("Recording stopped: {RecordPath}", recordingPath);
+            recordingStatus = RecordingStatus.Completed;
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error recording to {RecordPath}", recordingPath);
+            recordingStatus = RecordingStatus.Error;
+        }
+
+        if (!string.IsNullOrWhiteSpace(liveStreamId))
+        {
+            try
+            {
+                await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error closing live stream");
+            }
+        }
+
+        DeleteFileIfEmpty(recordingPath);
+        TriggerRefresh(recordingPath);
+        _libraryMonitor.ReportFileSystemChangeComplete(recordingPath, false);
+        _activeRecordings.TryRemove(timer.Id, out _);
+
+        if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10)
+        {
+            const int RetryIntervalSeconds = 60;
+            _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds);
+
+            timer.Status = RecordingStatus.New;
+            timer.PrePaddingSeconds = 0;
+            timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds);
+            timer.RetryCount++;
+            _timerManager.AddOrUpdate(timer);
+        }
+        else if (File.Exists(recordingPath))
+        {
+            timer.RecordingPath = recordingPath;
+            timer.Status = RecordingStatus.Completed;
+            _timerManager.AddOrUpdate(timer, false);
+            PostProcessRecording(recordingPath);
+        }
+        else
+        {
+            _timerManager.Delete(timer);
+        }
+    }
+
+    /// <inheritdoc />
+    public void Dispose()
+    {
+        if (_disposed)
+        {
+            return;
+        }
+
+        _recordingDeleteSemaphore.Dispose();
+
+        foreach (var pair in _activeRecordings.ToList())
+        {
+            pair.Value.CancellationTokenSource.Cancel();
+        }
+
+        _disposed = true;
+    }
+
+    private async void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e)
+    {
+        if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
+        {
+            await CreateRecordingFolders().ConfigureAwait(false);
+        }
+    }
+
+    private async Task<RemoteSearchResult?> FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken)
+    {
+        if (!timer.IsSeries || timer.SeriesProviderIds.Count == 0)
+        {
+            return null;
+        }
+
+        var query = new RemoteSearchQuery<SeriesInfo>
+        {
+            SearchInfo = new SeriesInfo
+            {
+                ProviderIds = timer.SeriesProviderIds,
+                Name = timer.Name,
+                MetadataCountryCode = _config.Configuration.MetadataCountryCode,
+                MetadataLanguage = _config.Configuration.PreferredMetadataLanguage
+            }
+        };
+
+        var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, cancellationToken).ConfigureAwait(false);
+
+        return results.FirstOrDefault();
+    }
+
+    private string GetRecordingPath(TimerInfo timer, RemoteSearchResult? metadata, out string? seriesPath)
+    {
+        var recordingPath = DefaultRecordingPath;
+        var config = _config.GetLiveTvConfiguration();
+        seriesPath = null;
+
+        if (timer.IsProgramSeries)
+        {
+            var customRecordingPath = config.SeriesRecordingPath;
+            var allowSubfolder = true;
+            if (!string.IsNullOrWhiteSpace(customRecordingPath))
+            {
+                allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
+                recordingPath = customRecordingPath;
+            }
+
+            if (allowSubfolder && config.EnableRecordingSubfolders)
+            {
+                recordingPath = Path.Combine(recordingPath, "Series");
+            }
+
+            // trim trailing period from the folder name
+            var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim();
+
+            if (metadata is not null && metadata.ProductionYear.HasValue)
+            {
+                folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+            }
+
+            // Can't use the year here in the folder name because it is the year of the episode, not the series.
+            recordingPath = Path.Combine(recordingPath, folderName);
+
+            seriesPath = recordingPath;
+
+            if (timer.SeasonNumber.HasValue)
+            {
+                folderName = string.Format(
+                    CultureInfo.InvariantCulture,
+                    "Season {0}",
+                    timer.SeasonNumber.Value);
+                recordingPath = Path.Combine(recordingPath, folderName);
+            }
+        }
+        else if (timer.IsMovie)
+        {
+            var customRecordingPath = config.MovieRecordingPath;
+            var allowSubfolder = true;
+            if (!string.IsNullOrWhiteSpace(customRecordingPath))
+            {
+                allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
+                recordingPath = customRecordingPath;
+            }
+
+            if (allowSubfolder && config.EnableRecordingSubfolders)
+            {
+                recordingPath = Path.Combine(recordingPath, "Movies");
+            }
+
+            var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
+            if (timer.ProductionYear.HasValue)
+            {
+                folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+            }
+
+            // trim trailing period from the folder name
+            folderName = folderName.TrimEnd('.').Trim();
+
+            recordingPath = Path.Combine(recordingPath, folderName);
+        }
+        else if (timer.IsKids)
+        {
+            if (config.EnableRecordingSubfolders)
+            {
+                recordingPath = Path.Combine(recordingPath, "Kids");
+            }
+
+            var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
+            if (timer.ProductionYear.HasValue)
+            {
+                folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+            }
+
+            // trim trailing period from the folder name
+            folderName = folderName.TrimEnd('.').Trim();
+
+            recordingPath = Path.Combine(recordingPath, folderName);
+        }
+        else if (timer.IsSports)
+        {
+            if (config.EnableRecordingSubfolders)
+            {
+                recordingPath = Path.Combine(recordingPath, "Sports");
+            }
+
+            recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
+        }
+        else
+        {
+            if (config.EnableRecordingSubfolders)
+            {
+                recordingPath = Path.Combine(recordingPath, "Other");
+            }
+
+            recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
+        }
+
+        var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts";
+
+        return Path.Combine(recordingPath, recordingFileName);
+    }
+
+    private void DeleteFileIfEmpty(string path)
+    {
+        var file = _fileSystem.GetFileInfo(path);
+
+        if (file.Exists && file.Length == 0)
+        {
+            try
+            {
+                _fileSystem.DeleteFile(path);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path);
+            }
+        }
+    }
+
+    private void TriggerRefresh(string path)
+    {
+        _logger.LogInformation("Triggering refresh on {Path}", path);
+
+        var item = GetAffectedBaseItem(Path.GetDirectoryName(path));
+        if (item is null)
+        {
+            return;
+        }
+
+        _logger.LogInformation("Refreshing recording parent {Path}", item.Path);
+        _providerManager.QueueRefresh(
+            item.Id,
+            new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+            {
+                RefreshPaths =
+                [
+                    path,
+                    Path.GetDirectoryName(path),
+                    Path.GetDirectoryName(Path.GetDirectoryName(path))
+                ]
+            },
+            RefreshPriority.High);
+    }
+
+    private BaseItem? GetAffectedBaseItem(string? path)
+    {
+        BaseItem? item = null;
+        var parentPath = Path.GetDirectoryName(path);
+        while (item is null && !string.IsNullOrEmpty(path))
+        {
+            item = _libraryManager.FindByPath(path, null);
+            path = Path.GetDirectoryName(path);
+        }
+
+        if (item is not null
+            && item.GetType() == typeof(Folder)
+            && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase))
+        {
+            var parentItem = item.GetParent();
+            if (parentItem is not null && parentItem is not AggregateFolder)
+            {
+                item = parentItem;
+            }
+        }
+
+        return item;
+    }
+
+    private async Task EnforceKeepUpTo(TimerInfo timer, string? seriesPath)
+    {
+        if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)
+            || string.IsNullOrWhiteSpace(seriesPath))
+        {
+            return;
+        }
+
+        var seriesTimerId = timer.SeriesTimerId;
+        var seriesTimer = _seriesTimerManager.GetAll()
+            .FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase));
+
+        if (seriesTimer is null || seriesTimer.KeepUpTo <= 0)
+        {
+            return;
+        }
+
+        if (_disposed)
+        {
+            return;
+        }
+
+        using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false))
+        {
+            if (_disposed)
+            {
+                return;
+            }
+
+            var timersToDelete = _timerManager.GetAll()
+                .Where(timerInfo => timerInfo.Status == RecordingStatus.Completed
+                    && !string.IsNullOrWhiteSpace(timerInfo.RecordingPath)
+                    && string.Equals(timerInfo.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)
+                    && File.Exists(timerInfo.RecordingPath))
+                .OrderByDescending(i => i.EndDate)
+                .Skip(seriesTimer.KeepUpTo - 1)
+                .ToList();
+
+            DeleteLibraryItemsForTimers(timersToDelete);
+
+            if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries)
+            {
+                return;
+            }
+
+            var episodesToDelete = librarySeries.GetItemList(
+                    new InternalItemsQuery
+                    {
+                        OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending)],
+                        IsVirtualItem = false,
+                        IsFolder = false,
+                        Recursive = true,
+                        DtoOptions = new DtoOptions(true)
+                    })
+                .Where(i => i.IsFileProtocol && File.Exists(i.Path))
+                .Skip(seriesTimer.KeepUpTo - 1);
+
+            foreach (var item in episodesToDelete)
+            {
+                try
+                {
+                    _libraryManager.DeleteItem(
+                        item,
+                        new DeleteOptions
+                        {
+                            DeleteFileLocation = true
+                        },
+                        true);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error deleting item");
+                }
+            }
+        }
+    }
+
+    private void DeleteLibraryItemsForTimers(List<TimerInfo> timers)
+    {
+        foreach (var timer in timers)
+        {
+            if (_disposed)
+            {
+                return;
+            }
+
+            try
+            {
+                DeleteLibraryItemForTimer(timer);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error deleting recording");
+            }
+        }
+    }
+
+    private void DeleteLibraryItemForTimer(TimerInfo timer)
+    {
+        var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false);
+        if (libraryItem is not null)
+        {
+            _libraryManager.DeleteItem(
+                libraryItem,
+                new DeleteOptions
+                {
+                    DeleteFileLocation = true
+                },
+                true);
+        }
+        else if (File.Exists(timer.RecordingPath))
+        {
+            _fileSystem.DeleteFile(timer.RecordingPath);
+        }
+
+        _timerManager.Delete(timer);
+    }
+
+    private string EnsureFileUnique(string path, string timerId)
+    {
+        var parent = Path.GetDirectoryName(path)!;
+        var name = Path.GetFileNameWithoutExtension(path);
+        var extension = Path.GetExtension(path);
+
+        var index = 1;
+        while (File.Exists(path) || _activeRecordings.Any(i
+                   => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase)
+                      && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)))
+        {
+            name += " - " + index.ToString(CultureInfo.InvariantCulture);
+
+            path = Path.ChangeExtension(Path.Combine(parent, name), extension);
+            index++;
+        }
+
+        return path;
+    }
+
+    private IRecorder GetRecorder(MediaSourceInfo mediaSource)
+    {
+        if (mediaSource.RequiresLooping
+            || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase)
+            || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http))
+        {
+            return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config);
+        }
+
+        return new DirectRecorder(_logger, _httpClientFactory, _streamHelper);
+    }
+
+    private void PostProcessRecording(string path)
+    {
+        var options = _config.GetLiveTvConfiguration();
+        if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor))
+        {
+            return;
+        }
+
+        try
+        {
+            var process = new Process
+            {
+                StartInfo = new ProcessStartInfo
+                {
+                    Arguments = options.RecordingPostProcessorArguments
+                        .Replace("{path}", path, StringComparison.OrdinalIgnoreCase),
+                    CreateNoWindow = true,
+                    ErrorDialog = false,
+                    FileName = options.RecordingPostProcessor,
+                    WindowStyle = ProcessWindowStyle.Hidden,
+                    UseShellExecute = false
+                },
+                EnableRaisingEvents = true
+            };
+
+            _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+            process.Exited += OnProcessExited;
+            process.Start();
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error running recording post processor");
+        }
+    }
+
+    private void OnProcessExited(object? sender, EventArgs e)
+    {
+        if (sender is Process process)
+        {
+            using (process)
+            {
+                _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode);
+            }
+        }
+    }
+}

+ 1 - 1
tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs

@@ -26,7 +26,7 @@ public class AudioResolverTests
     public AudioResolverTests()
     {
         // prep BaseItem and Video for calls made that expect managers
-        Video.LiveTvManager = Mock.Of<ILiveTvManager>();
+        Video.RecordingsManager = Mock.Of<IRecordingsManager>();
 
         var applicationPaths = new Mock<IServerApplicationPaths>().Object;
         var serverConfig = new Mock<IServerConfigurationManager>();

+ 1 - 1
tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs

@@ -37,7 +37,7 @@ public class MediaInfoResolverTests
     public MediaInfoResolverTests()
     {
         // prep BaseItem and Video for calls made that expect managers
-        Video.LiveTvManager = Mock.Of<ILiveTvManager>();
+        Video.RecordingsManager = Mock.Of<IRecordingsManager>();
 
         var applicationPaths = new Mock<IServerApplicationPaths>().Object;
         var serverConfig = new Mock<IServerConfigurationManager>();

+ 1 - 1
tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs

@@ -26,7 +26,7 @@ public class SubtitleResolverTests
     public SubtitleResolverTests()
     {
         // prep BaseItem and Video for calls made that expect managers
-        Video.LiveTvManager = Mock.Of<ILiveTvManager>();
+        Video.RecordingsManager = Mock.Of<IRecordingsManager>();
 
         var applicationPaths = new Mock<IServerApplicationPaths>().Object;
         var serverConfig = new Mock<IServerConfigurationManager>();

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä