Merge pull request #11045 from barronpm/livetv-recordingsmanager

LiveTV Recordings Refactor
This commit is contained in:
Cody Robibero 2024-02-21 14:24:50 -07:00 committed by GitHub
commit ca21a80c95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1570 additions and 1441 deletions

View File

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

View File

@ -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;

View File

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

View File

@ -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()

View File

@ -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);

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

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

View File

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

View File

@ -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);

View File

@ -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
{

View File

@ -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
{

View File

@ -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
{

View File

@ -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
{

View File

@ -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
{

View File

@ -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))

View File

@ -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)
{

View File

@ -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");
}
}
}

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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