فهرست منبع

Add RecordingsMetadataManager service

Patrick Barron 1 سال پیش
والد
کامیت
7baf2d6c6b

+ 6 - 452
src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs

@@ -10,16 +10,15 @@ using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Net.Http;
-using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
-using System.Xml;
 using AsyncKeyedLock;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Events;
 using Jellyfin.Extensions;
 using Jellyfin.LiveTv.Configuration;
 using Jellyfin.LiveTv.IO;
+using Jellyfin.LiveTv.Recordings;
 using Jellyfin.LiveTv.Timers;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
@@ -44,8 +43,6 @@ namespace Jellyfin.LiveTv.EmbyTV
 {
     public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable
     {
-        public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss";
-
         private readonly ILogger<EmbyTV> _logger;
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IServerConfigurationManager _config;
@@ -61,6 +58,7 @@ namespace Jellyfin.LiveTv.EmbyTV
         private readonly LiveTvDtoService _tvDtoService;
         private readonly TimerManager _timerManager;
         private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerManager;
+        private readonly RecordingsMetadataManager _recordingsMetadataManager;
 
         private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings =
             new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase);
@@ -84,7 +82,8 @@ namespace Jellyfin.LiveTv.EmbyTV
             IListingsManager listingsManager,
             LiveTvDtoService tvDtoService,
             TimerManager timerManager,
-            SeriesTimerManager seriesTimerManager)
+            SeriesTimerManager seriesTimerManager,
+            RecordingsMetadataManager recordingsMetadataManager)
         {
             Current = this;
 
@@ -103,6 +102,7 @@ namespace Jellyfin.LiveTv.EmbyTV
             _tvDtoService = tvDtoService;
             _timerManager = timerManager;
             _seriesTimerManager = seriesTimerManager;
+            _recordingsMetadataManager = recordingsMetadataManager;
 
             _timerManager.TimerFired += OnTimerManagerTimerFired;
             _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
@@ -998,7 +998,7 @@ namespace Jellyfin.LiveTv.EmbyTV
                     timer.Status = RecordingStatus.InProgress;
                     _timerManager.AddOrUpdate(timer, false);
 
-                    await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false);
+                    await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false);
 
                     await CreateRecordingFolders().ConfigureAwait(false);
 
@@ -1377,452 +1377,6 @@ namespace Jellyfin.LiveTv.EmbyTV
             }
         }
 
-        private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image)
-        {
-            if (!image.IsLocalFile)
-            {
-                image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false);
-            }
-
-            string 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);
-        }
-
-        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 SaveRecordingMetadata(TimerInfo timer, string recordingPath, string seriesPath)
-        {
-            try
-            {
-                var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery
-                {
-                    IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
-                    Limit = 1,
-                    ExternalId = timer.ProgramId,
-                    DtoOptions = new DtoOptions(true)
-                }).FirstOrDefault() as LiveTvProgram;
-
-                // dummy this up
-                if (program is null)
-                {
-                    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)
-                    {
-                        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 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 LiveTvProgram GetProgramInfoFromCache(string programId)
         {
             var query = new InternalItemsQuery

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

@@ -2,6 +2,7 @@
 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;
@@ -26,6 +27,7 @@ public static class LiveTvServiceCollectionExtensions
         services.AddSingleton<LiveTvDtoService>();
         services.AddSingleton<TimerManager>();
         services.AddSingleton<SeriesTimerManager>();
+        services.AddSingleton<RecordingsMetadataManager>();
         services.AddSingleton<ILiveTvManager, LiveTvManager>();
         services.AddSingleton<IChannelManager, ChannelManager>();
         services.AddSingleton<IStreamHelper, StreamHelper>();

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