Bläddra i källkod

Merge pull request #11045 from barronpm/livetv-recordingsmanager

LiveTV Recordings Refactor
Cody Robibero 1 år sedan
förälder
incheckning
ca21a80c95
26 ändrade filer med 1630 tillägg och 1447 borttagningar
  1. 1 1
      Emby.Server.Implementations/ApplicationHost.cs
  2. 5 3
      Emby.Server.Implementations/Dto/DtoService.cs
  3. 5 2
      Jellyfin.Api/Controllers/LiveTvController.cs
  4. 2 2
      MediaBrowser.Controller/Entities/Video.cs
  5. 0 4
      MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
  6. 55 0
      MediaBrowser.Controller/LiveTv/IRecordingsManager.cs
  7. 133 1356
      src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
  8. 13 7
      src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs
  9. 0 24
      src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs
  10. 8 0
      src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
  11. 5 1
      src/Jellyfin.LiveTv/Guide/GuideManager.cs
  12. 1 1
      src/Jellyfin.LiveTv/IO/DirectRecorder.cs
  13. 1 1
      src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
  14. 1 1
      src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs
  15. 1 1
      src/Jellyfin.LiveTv/IO/IRecorder.cs
  16. 1 1
      src/Jellyfin.LiveTv/IO/StreamHelper.cs
  17. 8 14
      src/Jellyfin.LiveTv/LiveTvManager.cs
  18. 4 2
      src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
  19. 838 0
      src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
  20. 502 0
      src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
  21. 1 1
      src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
  22. 29 0
      src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs
  23. 13 22
      src/Jellyfin.LiveTv/Timers/TimerManager.cs
  24. 1 1
      tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
  25. 1 1
      tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
  26. 1 1
      tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs

+ 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);
+}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 133 - 1356
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;

+ 0 - 24
src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs

@@ -1,24 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using MediaBrowser.Controller.LiveTv;
-using Microsoft.Extensions.Logging;
-
-namespace Jellyfin.LiveTv.EmbyTV
-{
-    public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo>
-    {
-        public SeriesTimerManager(ILogger logger, string dataPath)
-            : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
-        {
-        }
-
-        /// <inheritdoc />
-        public override void Add(SeriesTimerInfo item)
-        {
-            ArgumentException.ThrowIfNullOrEmpty(item.Id);
-
-            base.Add(item);
-        }
-    }
-}

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

@@ -1,6 +1,9 @@
 using Jellyfin.LiveTv.Channels;
 using Jellyfin.LiveTv.Guide;
+using Jellyfin.LiveTv.IO;
 using Jellyfin.LiveTv.Listings;
+using Jellyfin.LiveTv.Recordings;
+using Jellyfin.LiveTv.Timers;
 using Jellyfin.LiveTv.TunerHosts;
 using Jellyfin.LiveTv.TunerHosts.HdHomerun;
 using MediaBrowser.Controller.Channels;
@@ -22,12 +25,17 @@ public static class LiveTvServiceCollectionExtensions
     public static void AddLiveTvServices(this IServiceCollection services)
     {
         services.AddSingleton<LiveTvDtoService>();
+        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);
 

+ 1 - 1
src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs → src/Jellyfin.LiveTv/IO/DirectRecorder.cs

@@ -12,7 +12,7 @@ using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.IO;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.IO
 {
     public sealed class DirectRecorder : IRecorder
     {

+ 1 - 1
src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs → src/Jellyfin.LiveTv/IO/EncodedRecorder.cs

@@ -23,7 +23,7 @@ using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.IO;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.IO
 {
     public class EncodedRecorder : IRecorder
     {

+ 1 - 1
src/Jellyfin.LiveTv/ExclusiveLiveStream.cs → src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs

@@ -11,7 +11,7 @@ using System.Threading.Tasks;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
 
-namespace Jellyfin.LiveTv
+namespace Jellyfin.LiveTv.IO
 {
     public sealed class ExclusiveLiveStream : ILiveStream
     {

+ 1 - 1
src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs → src/Jellyfin.LiveTv/IO/IRecorder.cs

@@ -6,7 +6,7 @@ using System.Threading.Tasks;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
 
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.IO
 {
     public interface IRecorder : IDisposable
     {

+ 1 - 1
src/Jellyfin.LiveTv/StreamHelper.cs → src/Jellyfin.LiveTv/IO/StreamHelper.cs

@@ -7,7 +7,7 @@ using System.Threading;
 using System.Threading.Tasks;
 using MediaBrowser.Model.IO;
 
-namespace Jellyfin.LiveTv
+namespace Jellyfin.LiveTv.IO
 {
     public class StreamHelper : IStreamHelper
     {

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

@@ -12,6 +12,7 @@ using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Events;
 using Jellyfin.LiveTv.Configuration;
+using Jellyfin.LiveTv.IO;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Configuration;
@@ -42,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;
 
@@ -54,6 +56,7 @@ namespace Jellyfin.LiveTv
             ILibraryManager libraryManager,
             ILocalizationManager localization,
             IChannelManager channelManager,
+            IRecordingsManager recordingsManager,
             LiveTvDtoService liveTvDtoService,
             IEnumerable<ILiveTvService> services)
         {
@@ -66,6 +69,7 @@ namespace Jellyfin.LiveTv
             _userDataManager = userDataManager;
             _channelManager = channelManager;
             _tvDtoService = liveTvDtoService;
+            _recordingsManager = recordingsManager;
             _services = services.ToArray();
 
             var defaultService = _services.OfType<EmbyTV.EmbyTV>().First();
@@ -87,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;
@@ -764,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
@@ -1460,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)
                 {

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

@@ -0,0 +1,838 @@
+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);
+            await PostProcessRecording(recordingPath).ConfigureAwait(false);
+        }
+        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 async Task PostProcessRecording(string path)
+    {
+        var options = _config.GetLiveTvConfiguration();
+        if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor))
+        {
+            return;
+        }
+
+        try
+        {
+            using var process = new Process();
+            process.StartInfo = new ProcessStartInfo
+            {
+                Arguments = options.RecordingPostProcessorArguments
+                    .Replace("{path}", path, StringComparison.OrdinalIgnoreCase),
+                CreateNoWindow = true,
+                ErrorDialog = false,
+                FileName = options.RecordingPostProcessor,
+                WindowStyle = ProcessWindowStyle.Hidden,
+                UseShellExecute = false
+            };
+            process.EnableRaisingEvents = true;
+
+            _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+            process.Start();
+            await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
+
+            _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error running recording post processor");
+        }
+    }
+}

+ 502 - 0
src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs

@@ -0,0 +1,502 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml;
+using Jellyfin.Data.Enums;
+using Jellyfin.Extensions;
+using Jellyfin.LiveTv.Configuration;
+using Jellyfin.LiveTv.EmbyTV;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.Recordings;
+
+/// <summary>
+/// A service responsible for saving recording metadata.
+/// </summary>
+public class RecordingsMetadataManager
+{
+    private const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss";
+
+    private readonly ILogger<RecordingsMetadataManager> _logger;
+    private readonly IConfigurationManager _config;
+    private readonly ILibraryManager _libraryManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="RecordingsMetadataManager"/> class.
+    /// </summary>
+    /// <param name="logger">The <see cref="ILogger"/>.</param>
+    /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
+    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
+    public RecordingsMetadataManager(
+        ILogger<RecordingsMetadataManager> logger,
+        IConfigurationManager config,
+        ILibraryManager libraryManager)
+    {
+        _logger = logger;
+        _config = config;
+        _libraryManager = libraryManager;
+    }
+
+    /// <summary>
+    /// Saves the metadata for a provided recording.
+    /// </summary>
+    /// <param name="timer">The recording timer.</param>
+    /// <param name="recordingPath">The recording path.</param>
+    /// <param name="seriesPath">The series path.</param>
+    /// <returns>A task representing the metadata saving.</returns>
+    public async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string? seriesPath)
+    {
+        try
+        {
+            var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery
+            {
+                IncludeItemTypes = [BaseItemKind.LiveTvProgram],
+                Limit = 1,
+                ExternalId = timer.ProgramId,
+                DtoOptions = new DtoOptions(true)
+            }).FirstOrDefault() as LiveTvProgram;
+
+            // dummy this up
+            program ??= new LiveTvProgram
+            {
+                Name = timer.Name,
+                Overview = timer.Overview,
+                Genres = timer.Genres,
+                CommunityRating = timer.CommunityRating,
+                OfficialRating = timer.OfficialRating,
+                ProductionYear = timer.ProductionYear,
+                PremiereDate = timer.OriginalAirDate,
+                IndexNumber = timer.EpisodeNumber,
+                ParentIndexNumber = timer.SeasonNumber
+            };
+
+            if (timer.IsSports)
+            {
+                program.AddGenre("Sports");
+            }
+
+            if (timer.IsKids)
+            {
+                program.AddGenre("Kids");
+                program.AddGenre("Children");
+            }
+
+            if (timer.IsNews)
+            {
+                program.AddGenre("News");
+            }
+
+            var config = _config.GetLiveTvConfiguration();
+
+            if (config.SaveRecordingNFO)
+            {
+                if (timer.IsProgramSeries)
+                {
+                    ArgumentNullException.ThrowIfNull(seriesPath);
+
+                    await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
+                    await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
+                }
+                else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
+                {
+                    await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false);
+                }
+                else
+                {
+                    await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
+                }
+            }
+
+            if (config.SaveRecordingImages)
+            {
+                await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
+            }
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error saving nfo");
+        }
+    }
+
+    private static async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath)
+    {
+        var nfoPath = Path.Combine(seriesPath, "tvshow.nfo");
+
+        if (File.Exists(nfoPath))
+        {
+            return;
+        }
+
+        var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+        await using (stream.ConfigureAwait(false))
+        {
+            var settings = new XmlWriterSettings
+            {
+                Indent = true,
+                Encoding = Encoding.UTF8,
+                Async = true
+            };
+
+            var writer = XmlWriter.Create(stream, settings);
+            await using (writer.ConfigureAwait(false))
+            {
+                await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
+                await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false);
+                if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id))
+                {
+                    await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false);
+                }
+
+                if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id))
+                {
+                    await writer.WriteElementStringAsync(null, "imdb_id", null, id).ConfigureAwait(false);
+                }
+
+                if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id))
+                {
+                    await writer.WriteElementStringAsync(null, "tmdbid", null, id).ConfigureAwait(false);
+                }
+
+                if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id))
+                {
+                    await writer.WriteElementStringAsync(null, "zap2itid", null, id).ConfigureAwait(false);
+                }
+
+                if (!string.IsNullOrWhiteSpace(timer.Name))
+                {
+                    await writer.WriteElementStringAsync(null, "title", null, timer.Name).ConfigureAwait(false);
+                }
+
+                if (!string.IsNullOrWhiteSpace(timer.OfficialRating))
+                {
+                    await writer.WriteElementStringAsync(null, "mpaa", null, timer.OfficialRating).ConfigureAwait(false);
+                }
+
+                foreach (var genre in timer.Genres)
+                {
+                    await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false);
+                }
+
+                await writer.WriteEndElementAsync().ConfigureAwait(false);
+                await writer.WriteEndDocumentAsync().ConfigureAwait(false);
+            }
+        }
+    }
+
+    private async Task SaveVideoNfoAsync(TimerInfo timer, string recordingPath, BaseItem item, bool lockData)
+    {
+        var nfoPath = Path.ChangeExtension(recordingPath, ".nfo");
+
+        if (File.Exists(nfoPath))
+        {
+            return;
+        }
+
+        var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+        await using (stream.ConfigureAwait(false))
+        {
+            var settings = new XmlWriterSettings
+            {
+                Indent = true,
+                Encoding = Encoding.UTF8,
+                Async = true
+            };
+
+            var options = _config.GetNfoConfiguration();
+
+            var isSeriesEpisode = timer.IsProgramSeries;
+
+            var writer = XmlWriter.Create(stream, settings);
+            await using (writer.ConfigureAwait(false))
+            {
+                await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
+
+                if (isSeriesEpisode)
+                {
+                    await writer.WriteStartElementAsync(null, "episodedetails", null).ConfigureAwait(false);
+
+                    if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle))
+                    {
+                        await writer.WriteElementStringAsync(null, "title", null, timer.EpisodeTitle).ConfigureAwait(false);
+                    }
+
+                    var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null);
+
+                    if (premiereDate.HasValue)
+                    {
+                        var formatString = options.ReleaseDateFormat;
+
+                        await writer.WriteElementStringAsync(
+                            null,
+                            "aired",
+                            null,
+                            premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
+                    }
+
+                    if (item.IndexNumber.HasValue)
+                    {
+                        await writer.WriteElementStringAsync(null, "episode", null, item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+                    }
+
+                    if (item.ParentIndexNumber.HasValue)
+                    {
+                        await writer.WriteElementStringAsync(null, "season", null, item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+                    }
+                }
+                else
+                {
+                    await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false);
+
+                    if (!string.IsNullOrWhiteSpace(item.Name))
+                    {
+                        await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false);
+                    }
+
+                    if (!string.IsNullOrWhiteSpace(item.OriginalTitle))
+                    {
+                        await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureAwait(false);
+                    }
+
+                    if (item.PremiereDate.HasValue)
+                    {
+                        var formatString = options.ReleaseDateFormat;
+
+                        await writer.WriteElementStringAsync(
+                            null,
+                            "premiered",
+                            null,
+                            item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
+                        await writer.WriteElementStringAsync(
+                            null,
+                            "releasedate",
+                            null,
+                            item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
+                    }
+                }
+
+                await writer.WriteElementStringAsync(
+                    null,
+                    "dateadded",
+                    null,
+                    DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false);
+
+                if (item.ProductionYear.HasValue)
+                {
+                    await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+                }
+
+                if (!string.IsNullOrEmpty(item.OfficialRating))
+                {
+                    await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false);
+                }
+
+                var overview = (item.Overview ?? string.Empty)
+                    .StripHtml()
+                    .Replace("&quot;", "'", StringComparison.Ordinal);
+
+                await writer.WriteElementStringAsync(null, "plot", null, overview).ConfigureAwait(false);
+
+                if (item.CommunityRating.HasValue)
+                {
+                    await writer.WriteElementStringAsync(null, "rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+                }
+
+                foreach (var genre in item.Genres)
+                {
+                    await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false);
+                }
+
+                var people = item.Id.IsEmpty() ? new List<PersonInfo>() : _libraryManager.GetPeople(item);
+
+                var directors = people
+                    .Where(i => i.IsType(PersonKind.Director))
+                    .Select(i => i.Name)
+                    .ToList();
+
+                foreach (var person in directors)
+                {
+                    await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false);
+                }
+
+                var writers = people
+                    .Where(i => i.IsType(PersonKind.Writer))
+                    .Select(i => i.Name)
+                    .Distinct(StringComparer.OrdinalIgnoreCase)
+                    .ToList();
+
+                foreach (var person in writers)
+                {
+                    await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false);
+                }
+
+                foreach (var person in writers)
+                {
+                    await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false);
+                }
+
+                var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection);
+
+                if (!string.IsNullOrEmpty(tmdbCollection))
+                {
+                    await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false);
+                }
+
+                var imdb = item.GetProviderId(MetadataProvider.Imdb);
+                if (!string.IsNullOrEmpty(imdb))
+                {
+                    if (!isSeriesEpisode)
+                    {
+                        await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false);
+                    }
+
+                    await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false);
+
+                    // No need to lock if we have identified the content already
+                    lockData = false;
+                }
+
+                var tvdb = item.GetProviderId(MetadataProvider.Tvdb);
+                if (!string.IsNullOrEmpty(tvdb))
+                {
+                    await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false);
+
+                    // No need to lock if we have identified the content already
+                    lockData = false;
+                }
+
+                var tmdb = item.GetProviderId(MetadataProvider.Tmdb);
+                if (!string.IsNullOrEmpty(tmdb))
+                {
+                    await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false);
+
+                    // No need to lock if we have identified the content already
+                    lockData = false;
+                }
+
+                if (lockData)
+                {
+                    await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false);
+                }
+
+                if (item.CriticRating.HasValue)
+                {
+                    await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+                }
+
+                if (!string.IsNullOrWhiteSpace(item.Tagline))
+                {
+                    await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false);
+                }
+
+                foreach (var studio in item.Studios)
+                {
+                    await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false);
+                }
+
+                await writer.WriteEndElementAsync().ConfigureAwait(false);
+                await writer.WriteEndDocumentAsync().ConfigureAwait(false);
+            }
+        }
+    }
+
+    private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program)
+    {
+        var image = program.IsSeries ?
+            (program.GetImageInfo(ImageType.Thumb, 0) ?? program.GetImageInfo(ImageType.Primary, 0)) :
+            (program.GetImageInfo(ImageType.Primary, 0) ?? program.GetImageInfo(ImageType.Thumb, 0));
+
+        if (image is not null)
+        {
+            try
+            {
+                await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error saving recording image");
+            }
+        }
+
+        if (!program.IsSeries)
+        {
+            image = program.GetImageInfo(ImageType.Backdrop, 0);
+            if (image is not null)
+            {
+                try
+                {
+                    await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error saving recording image");
+                }
+            }
+
+            image = program.GetImageInfo(ImageType.Thumb, 0);
+            if (image is not null)
+            {
+                try
+                {
+                    await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error saving recording image");
+                }
+            }
+
+            image = program.GetImageInfo(ImageType.Logo, 0);
+            if (image is not null)
+            {
+                try
+                {
+                    await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error saving recording image");
+                }
+            }
+        }
+    }
+
+    private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image)
+    {
+        if (!image.IsLocalFile)
+        {
+            image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false);
+        }
+
+        var imageSaveFilenameWithoutExtension = image.Type switch
+        {
+            ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster",
+            ImageType.Logo => "logo",
+            ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscape",
+            ImageType.Backdrop => "fanart",
+            _ => null
+        };
+
+        if (imageSaveFilenameWithoutExtension is null)
+        {
+            return;
+        }
+
+        var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath)!, imageSaveFilenameWithoutExtension);
+
+        // preserve original image extension
+        imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path));
+
+        File.Copy(image.Path, imageSavePath, true);
+    }
+}

+ 1 - 1
src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs → src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs

@@ -9,7 +9,7 @@ using System.Text.Json;
 using Jellyfin.Extensions.Json;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Timers
 {
     public class ItemDataProvider<T>
         where T : class

+ 29 - 0
src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs

@@ -0,0 +1,29 @@
+#pragma warning disable CS1591
+
+using System;
+using System.IO;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.Timers
+{
+    public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo>
+    {
+        public SeriesTimerManager(ILogger<SeriesTimerManager> logger, IConfigurationManager config)
+            : base(
+                logger,
+                Path.Combine(config.CommonApplicationPaths.DataPath, "livetv/seriestimers.json"),
+                (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
+        {
+        }
+
+        /// <inheritdoc />
+        public override void Add(SeriesTimerInfo item)
+        {
+            ArgumentException.ThrowIfNullOrEmpty(item.Id);
+
+            base.Add(item);
+        }
+    }
+}

+ 13 - 22
src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs → src/Jellyfin.LiveTv/Timers/TimerManager.cs

@@ -3,21 +3,27 @@
 using System;
 using System.Collections.Concurrent;
 using System.Globalization;
+using System.IO;
 using System.Linq;
 using System.Threading;
 using Jellyfin.Data.Events;
+using Jellyfin.LiveTv.EmbyTV;
+using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Model.LiveTv;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Timers
 {
     public class TimerManager : ItemDataProvider<TimerInfo>
     {
-        private readonly ConcurrentDictionary<string, Timer> _timers = new ConcurrentDictionary<string, Timer>(StringComparer.OrdinalIgnoreCase);
+        private readonly ConcurrentDictionary<string, Timer> _timers = new(StringComparer.OrdinalIgnoreCase);
 
-        public TimerManager(ILogger logger, string dataPath)
-            : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
+        public TimerManager(ILogger<TimerManager> logger, IConfigurationManager config)
+            : base(
+                logger,
+                Path.Combine(config.CommonApplicationPaths.DataPath, "livetv"),
+                (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
         {
         }
 
@@ -80,22 +86,11 @@ namespace Jellyfin.LiveTv.EmbyTV
             AddOrUpdateSystemTimer(item);
         }
 
-        private static bool ShouldStartTimer(TimerInfo item)
-        {
-            if (item.Status == RecordingStatus.Completed
-                || item.Status == RecordingStatus.Cancelled)
-            {
-                return false;
-            }
-
-            return true;
-        }
-
         private void AddOrUpdateSystemTimer(TimerInfo item)
         {
             StopTimer(item);
 
-            if (!ShouldStartTimer(item))
+            if (item.Status is RecordingStatus.Completed or RecordingStatus.Cancelled)
             {
                 return;
             }
@@ -169,13 +164,9 @@ namespace Jellyfin.LiveTv.EmbyTV
         }
 
         public TimerInfo? GetTimer(string id)
-        {
-            return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase));
-        }
+            => GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase));
 
         public TimerInfo? GetTimerByProgramId(string programId)
-        {
-            return GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase));
-        }
+            => GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase));
     }
 }

+ 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>();

Vissa filer visades inte eftersom för många filer har ändrats