aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs43
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs49
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs31
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs2
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs5
-rw-r--r--src/Jellyfin.Extensions/Json/JsonDefaults.cs1
-rw-r--r--src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs27
-rw-r--r--src/Jellyfin.Extensions/StringExtensions.cs10
-rw-r--r--src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs9
-rw-r--r--src/Jellyfin.LiveTv/DefaultLiveTvService.cs998
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs2540
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs31
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs19
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs24
-rw-r--r--src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs11
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs50
-rw-r--r--src/Jellyfin.LiveTv/IO/DirectRecorder.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs)2
-rw-r--r--src/Jellyfin.LiveTv/IO/EncodedRecorder.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs)2
-rw-r--r--src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs (renamed from src/Jellyfin.LiveTv/ExclusiveLiveStream.cs)2
-rw-r--r--src/Jellyfin.LiveTv/IO/IRecorder.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs)2
-rw-r--r--src/Jellyfin.LiveTv/IO/StreamHelper.cs (renamed from src/Jellyfin.LiveTv/StreamHelper.cs)2
-rw-r--r--src/Jellyfin.LiveTv/Listings/EpgChannelData.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs)2
-rw-r--r--src/Jellyfin.LiveTv/Listings/ListingsManager.cs461
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs383
-rw-r--r--src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs220
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs)9
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs (renamed from src/Jellyfin.LiveTv/RecordingNotifier.cs)2
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs37
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs837
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs501
-rw-r--r--src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs)2
-rw-r--r--src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs29
-rw-r--r--src/Jellyfin.LiveTv/Timers/TimerManager.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs)37
33 files changed, 3335 insertions, 3045 deletions
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index 4ae5a9a48..a40719499 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -19,8 +19,8 @@ namespace Jellyfin.Drawing.Skia;
/// </summary>
public class SkiaEncoder : IImageEncoder
{
+ private const string SvgFormat = "svg";
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
-
private readonly ILogger<SkiaEncoder> _logger;
private readonly IApplicationPaths _appPaths;
private static readonly SKImageFilter _imageFilter;
@@ -89,12 +89,13 @@ public class SkiaEncoder : IImageEncoder
// working on windows at least
"cr2",
"nef",
- "arw"
+ "arw",
+ SvgFormat
};
/// <inheritdoc/>
public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
- => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png };
+ => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png, ImageFormat.Svg };
/// <summary>
/// Check if the native lib is available.
@@ -312,6 +313,31 @@ public class SkiaEncoder : IImageEncoder
return Decode(path, false, orientation, out _);
}
+ private SKBitmap? GetBitmapFromSvg(string path)
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException("File not found", path);
+ }
+
+ using var svg = SKSvg.CreateFromFile(path);
+ if (svg.Drawable is null)
+ {
+ return null;
+ }
+
+ var width = (int)Math.Round(svg.Drawable.Bounds.Width);
+ var height = (int)Math.Round(svg.Drawable.Bounds.Height);
+
+ var bitmap = new SKBitmap(width, height);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.DrawPicture(svg.Picture);
+ canvas.Flush();
+ canvas.Save();
+
+ return bitmap;
+ }
+
private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
{
var needsFlip = origin is SKEncodedOrigin.LeftBottom or SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.RightTop;
@@ -402,6 +428,12 @@ public class SkiaEncoder : IImageEncoder
return inputPath;
}
+ if (outputFormat == ImageFormat.Svg
+ && !inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException($"Requested svg output from {inputFormat} input");
+ }
+
var skiaOutputFormat = GetImageFormat(outputFormat);
var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
@@ -409,7 +441,10 @@ public class SkiaEncoder : IImageEncoder
var blur = options.Blur ?? 0;
var hasIndicator = options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
- using var bitmap = GetBitmap(inputPath, autoOrient, orientation);
+ using var bitmap = inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase)
+ ? GetBitmapFromSvg(inputPath)
+ : GetBitmap(inputPath, autoOrient, orientation);
+
if (bitmap is null)
{
throw new InvalidDataException($"Skia unable to read image {inputPath}");
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs
new file mode 100644
index 000000000..06ecfc558
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs
@@ -0,0 +1,49 @@
+using System;
+using System.ComponentModel;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters;
+
+/// <summary>
+/// Json unknown enum converter.
+/// </summary>
+/// <typeparam name="T">The type of enum.</typeparam>
+public class JsonDefaultStringEnumConverter<T> : JsonConverter<T>
+ where T : struct, Enum
+{
+ private readonly JsonConverter<T> _baseConverter;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JsonDefaultStringEnumConverter{T}"/> class.
+ /// </summary>
+ /// <param name="baseConverter">The base json converter.</param>
+ public JsonDefaultStringEnumConverter(JsonConverter<T> baseConverter)
+ {
+ _baseConverter = baseConverter;
+ }
+
+ /// <inheritdoc />
+ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.IsNull() || reader.IsEmptyString())
+ {
+ var customValueAttribute = typeToConvert.GetCustomAttribute<DefaultValueAttribute>();
+ if (customValueAttribute?.Value is null)
+ {
+ throw new InvalidOperationException($"Default value not set for '{typeToConvert.Name}'");
+ }
+
+ return (T)customValueAttribute.Value;
+ }
+
+ return _baseConverter.Read(ref reader, typeToConvert, options);
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
+ {
+ _baseConverter.Write(writer, value, options);
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs
new file mode 100644
index 000000000..5a9bf546e
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs
@@ -0,0 +1,31 @@
+using System;
+using System.ComponentModel;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters;
+
+/// <summary>
+/// Utilizes the JsonStringEnumConverter and sets a default value if not provided.
+/// </summary>
+public class JsonDefaultStringEnumConverterFactory : JsonConverterFactory
+{
+ private static readonly JsonStringEnumConverter _baseConverterFactory = new();
+
+ /// <inheritdoc />
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return _baseConverterFactory.CanConvert(typeToConvert)
+ && typeToConvert.IsDefined(typeof(DefaultValueAttribute));
+ }
+
+ /// <inheritdoc />
+ public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ var baseConverter = _baseConverterFactory.CreateConverter(typeToConvert, options);
+ var converterType = typeof(JsonDefaultStringEnumConverter<>).MakeGenericType(typeToConvert);
+
+ return (JsonConverter?)Activator.CreateInstance(converterType, baseConverter);
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs
index ea6d141cb..2964c6943 100644
--- a/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs
@@ -12,7 +12,7 @@ namespace Jellyfin.Extensions.Json.Converters
{
/// <inheritdoc />
public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- => reader.TokenType == JsonTokenType.Null
+ => reader.IsNull()
? Guid.Empty
: ReadInternal(ref reader);
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs
index 28437023f..94004fa49 100644
--- a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs
@@ -15,10 +15,7 @@ namespace Jellyfin.Extensions.Json.Converters
/// <inheritdoc />
public override TStruct? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- // Token is empty string.
- if (reader.TokenType == JsonTokenType.String
- && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty)
- || (!reader.HasValueSequence && reader.ValueSpan.IsEmpty)))
+ if (reader.IsEmptyString())
{
return null;
}
diff --git a/src/Jellyfin.Extensions/Json/JsonDefaults.cs b/src/Jellyfin.Extensions/Json/JsonDefaults.cs
index 9e6d4c3f8..cbe5849ec 100644
--- a/src/Jellyfin.Extensions/Json/JsonDefaults.cs
+++ b/src/Jellyfin.Extensions/Json/JsonDefaults.cs
@@ -38,6 +38,7 @@ namespace Jellyfin.Extensions.Json
new JsonNullableGuidConverter(),
new JsonVersionConverter(),
new JsonFlagEnumConverterFactory(),
+ new JsonDefaultStringEnumConverterFactory(),
new JsonStringEnumConverter(),
new JsonNullableStructConverterFactory(),
new JsonDateTimeConverter(),
diff --git a/src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs b/src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs
new file mode 100644
index 000000000..d06508a26
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs
@@ -0,0 +1,27 @@
+using System.Text.Json;
+
+namespace Jellyfin.Extensions.Json;
+
+/// <summary>
+/// Extensions for Utf8JsonReader and Utf8JsonWriter.
+/// </summary>
+public static class Utf8JsonExtensions
+{
+ /// <summary>
+ /// Determines if the reader contains an empty string.
+ /// </summary>
+ /// <param name="reader">The reader.</param>
+ /// <returns>Whether the reader contains an empty string.</returns>
+ public static bool IsEmptyString(this Utf8JsonReader reader)
+ => reader.TokenType == JsonTokenType.String
+ && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty)
+ || (!reader.HasValueSequence && reader.ValueSpan.IsEmpty));
+
+ /// <summary>
+ /// Determines if the reader contains a null value.
+ /// </summary>
+ /// <param name="reader">The reader.</param>
+ /// <returns>Whether the reader contains null.</returns>
+ public static bool IsNull(this Utf8JsonReader reader)
+ => reader.TokenType == JsonTokenType.Null;
+}
diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs
index fd8f7e59a..9d8afc23c 100644
--- a/src/Jellyfin.Extensions/StringExtensions.cs
+++ b/src/Jellyfin.Extensions/StringExtensions.cs
@@ -61,6 +61,11 @@ namespace Jellyfin.Extensions
/// <returns>The part left of the <paramref name="needle" />.</returns>
public static ReadOnlySpan<char> LeftPart(this ReadOnlySpan<char> haystack, char needle)
{
+ if (haystack.IsEmpty)
+ {
+ return ReadOnlySpan<char>.Empty;
+ }
+
var pos = haystack.IndexOf(needle);
return pos == -1 ? haystack : haystack[..pos];
}
@@ -73,6 +78,11 @@ namespace Jellyfin.Extensions
/// <returns>The part right of the <paramref name="needle" />.</returns>
public static ReadOnlySpan<char> RightPart(this ReadOnlySpan<char> haystack, char needle)
{
+ if (haystack.IsEmpty)
+ {
+ return ReadOnlySpan<char>.Empty;
+ }
+
var pos = haystack.LastIndexOf(needle);
if (pos == -1)
{
diff --git a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
index 67d0e5295..f7888496f 100644
--- a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
+++ b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
@@ -1,4 +1,5 @@
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.LiveTv;
namespace Jellyfin.LiveTv.Configuration;
@@ -15,4 +16,12 @@ public static class LiveTvConfigurationExtensions
/// <returns>The <see cref="LiveTvOptions"/>.</returns>
public static LiveTvOptions GetLiveTvConfiguration(this IConfigurationManager configurationManager)
=> configurationManager.GetConfiguration<LiveTvOptions>("livetv");
+
+ /// <summary>
+ /// Gets the <see cref="XbmcMetadataOptions"/>.
+ /// </summary>
+ /// <param name="configurationManager">The <see cref="IConfigurationManager"/>.</param>
+ /// <returns>The <see cref="XbmcMetadataOptions"/>.</returns>
+ public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager)
+ => configurationManager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata");
}
diff --git a/src/Jellyfin.LiveTv/DefaultLiveTvService.cs b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs
new file mode 100644
index 000000000..318cc7acd
--- /dev/null
+++ b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs
@@ -0,0 +1,998 @@
+#nullable disable
+
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Data.Events;
+using Jellyfin.Extensions;
+using Jellyfin.LiveTv.Configuration;
+using Jellyfin.LiveTv.Timers;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv
+{
+ public sealed class DefaultLiveTvService : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds
+ {
+ public const string ServiceName = "Emby";
+
+ private readonly ILogger<DefaultLiveTvService> _logger;
+ private readonly IServerConfigurationManager _config;
+ private readonly ITunerHostManager _tunerHostManager;
+ private readonly IListingsManager _listingsManager;
+ private readonly IRecordingsManager _recordingsManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly LiveTvDtoService _tvDtoService;
+ private readonly TimerManager _timerManager;
+ private readonly SeriesTimerManager _seriesTimerManager;
+
+ public DefaultLiveTvService(
+ ILogger<DefaultLiveTvService> logger,
+ IServerConfigurationManager config,
+ ITunerHostManager tunerHostManager,
+ IListingsManager listingsManager,
+ IRecordingsManager recordingsManager,
+ ILibraryManager libraryManager,
+ LiveTvDtoService tvDtoService,
+ TimerManager timerManager,
+ SeriesTimerManager seriesTimerManager)
+ {
+ _logger = logger;
+ _config = config;
+ _libraryManager = libraryManager;
+ _tunerHostManager = tunerHostManager;
+ _listingsManager = listingsManager;
+ _recordingsManager = recordingsManager;
+ _tvDtoService = tvDtoService;
+ _timerManager = timerManager;
+ _seriesTimerManager = seriesTimerManager;
+
+ _timerManager.TimerFired += OnTimerManagerTimerFired;
+ }
+
+ public event EventHandler<GenericEventArgs<TimerInfo>> TimerCreated;
+
+ public event EventHandler<GenericEventArgs<string>> TimerCancelled;
+
+ /// <inheritdoc />
+ public string Name => ServiceName;
+
+ /// <inheritdoc />
+ public string HomePageUrl => "https://github.com/jellyfin/jellyfin";
+
+ public async Task RefreshSeriesTimers(CancellationToken cancellationToken)
+ {
+ var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var timer in seriesTimers)
+ {
+ UpdateTimersForSeriesTimer(timer, false, true);
+ }
+ }
+
+ public async Task RefreshTimers(CancellationToken cancellationToken)
+ {
+ var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false);
+
+ var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
+
+ foreach (var timer in timers)
+ {
+ if (DateTime.UtcNow > timer.EndDate && _recordingsManager.GetActiveRecordingPath(timer.Id) is null)
+ {
+ _timerManager.Delete(timer);
+ continue;
+ }
+
+ if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId))
+ {
+ continue;
+ }
+
+ var program = GetProgramInfoFromCache(timer);
+ if (program is null)
+ {
+ _timerManager.Delete(timer);
+ continue;
+ }
+
+ CopyProgramInfoToTimerInfo(program, timer, tempChannelCache);
+ _timerManager.Update(timer);
+ }
+ }
+
+ private async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken)
+ {
+ var channels = new List<ChannelInfo>();
+
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
+ {
+ try
+ {
+ var tunerChannels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false);
+
+ channels.AddRange(tunerChannels);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting channels");
+ }
+ }
+
+ await _listingsManager.AddProviderMetadata(channels, enableCache, cancellationToken).ConfigureAwait(false);
+
+ return channels;
+ }
+
+ public Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken)
+ {
+ return GetChannelsAsync(false, cancellationToken);
+ }
+
+ public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken)
+ {
+ var timers = _timerManager
+ .GetAll()
+ .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ foreach (var timer in timers)
+ {
+ CancelTimerInternal(timer.Id, true, true);
+ }
+
+ var remove = _seriesTimerManager.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase));
+ if (remove is not null)
+ {
+ _seriesTimerManager.Delete(remove);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation)
+ {
+ var timer = _timerManager.GetTimer(timerId);
+ if (timer is not null)
+ {
+ var statusChanging = timer.Status != RecordingStatus.Cancelled;
+ timer.Status = RecordingStatus.Cancelled;
+
+ if (isManualCancellation)
+ {
+ timer.IsManual = true;
+ }
+
+ if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled)
+ {
+ _timerManager.Delete(timer);
+ }
+ else
+ {
+ _timerManager.AddOrUpdate(timer, false);
+ }
+
+ if (statusChanging && TimerCancelled is not null)
+ {
+ TimerCancelled(this, new GenericEventArgs<string>(timerId));
+ }
+ }
+
+ _recordingsManager.CancelRecording(timerId, timer);
+ }
+
+ public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken)
+ {
+ CancelTimerInternal(timerId, false, true);
+ return Task.CompletedTask;
+ }
+
+ public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task<string> CreateTimer(TimerInfo info, CancellationToken cancellationToken)
+ {
+ var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ?
+ null :
+ _timerManager.GetTimerByProgramId(info.ProgramId);
+
+ if (existingTimer is not null)
+ {
+ if (existingTimer.Status == RecordingStatus.Cancelled
+ || existingTimer.Status == RecordingStatus.Completed)
+ {
+ existingTimer.Status = RecordingStatus.New;
+ existingTimer.IsManual = true;
+ _timerManager.Update(existingTimer);
+ return Task.FromResult(existingTimer.Id);
+ }
+
+ throw new ArgumentException("A scheduled recording already exists for this program.");
+ }
+
+ info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+
+ LiveTvProgram programInfo = null;
+
+ if (!string.IsNullOrWhiteSpace(info.ProgramId))
+ {
+ programInfo = GetProgramInfoFromCache(info);
+ }
+
+ if (programInfo is null)
+ {
+ _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId);
+ programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate);
+ }
+
+ if (programInfo is not null)
+ {
+ CopyProgramInfoToTimerInfo(programInfo, info);
+ }
+
+ info.IsManual = true;
+ _timerManager.Add(info);
+
+ TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(info));
+
+ return Task.FromResult(info.Id);
+ }
+
+ public async Task<string> CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken)
+ {
+ info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+
+ // populate info.seriesID
+ var program = GetProgramInfoFromCache(info.ProgramId);
+
+ if (program is not null)
+ {
+ info.SeriesId = program.ExternalSeriesId;
+ }
+ else
+ {
+ throw new InvalidOperationException("SeriesId for program not found");
+ }
+
+ // If any timers have already been manually created, make sure they don't get cancelled
+ var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false))
+ .Where(i =>
+ {
+ if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId))
+ {
+ return true;
+ }
+
+ if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId))
+ {
+ return true;
+ }
+
+ return false;
+ })
+ .ToList();
+
+ _seriesTimerManager.Add(info);
+
+ foreach (var timer in existingTimers)
+ {
+ timer.SeriesTimerId = info.Id;
+ timer.IsManual = true;
+
+ _timerManager.AddOrUpdate(timer, false);
+ }
+
+ UpdateTimersForSeriesTimer(info, true, false);
+
+ return info.Id;
+ }
+
+ public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
+ {
+ var instance = _seriesTimerManager.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (instance is not null)
+ {
+ instance.ChannelId = info.ChannelId;
+ instance.Days = info.Days;
+ instance.EndDate = info.EndDate;
+ instance.IsPostPaddingRequired = info.IsPostPaddingRequired;
+ instance.IsPrePaddingRequired = info.IsPrePaddingRequired;
+ instance.PostPaddingSeconds = info.PostPaddingSeconds;
+ instance.PrePaddingSeconds = info.PrePaddingSeconds;
+ instance.Priority = info.Priority;
+ instance.RecordAnyChannel = info.RecordAnyChannel;
+ instance.RecordAnyTime = info.RecordAnyTime;
+ instance.RecordNewOnly = info.RecordNewOnly;
+ instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary;
+ instance.KeepUpTo = info.KeepUpTo;
+ instance.KeepUntil = info.KeepUntil;
+ instance.StartDate = info.StartDate;
+
+ _seriesTimerManager.Update(instance);
+
+ UpdateTimersForSeriesTimer(instance, true, true);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken)
+ {
+ var existingTimer = _timerManager.GetTimer(updatedTimer.Id);
+
+ if (existingTimer is null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ // Only update if not currently active
+ if (_recordingsManager.GetActiveRecordingPath(updatedTimer.Id) is null)
+ {
+ existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds;
+ existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds;
+ existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired;
+ existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired;
+
+ _timerManager.Update(existingTimer);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer)
+ {
+ // Update the program info but retain the status
+ existingTimer.ChannelId = updatedTimer.ChannelId;
+ existingTimer.CommunityRating = updatedTimer.CommunityRating;
+ existingTimer.EndDate = updatedTimer.EndDate;
+ existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber;
+ existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle;
+ existingTimer.Genres = updatedTimer.Genres;
+ existingTimer.IsMovie = updatedTimer.IsMovie;
+ existingTimer.IsSeries = updatedTimer.IsSeries;
+ existingTimer.Tags = updatedTimer.Tags;
+ existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries;
+ existingTimer.IsRepeat = updatedTimer.IsRepeat;
+ existingTimer.Name = updatedTimer.Name;
+ existingTimer.OfficialRating = updatedTimer.OfficialRating;
+ existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate;
+ existingTimer.Overview = updatedTimer.Overview;
+ existingTimer.ProductionYear = updatedTimer.ProductionYear;
+ existingTimer.ProgramId = updatedTimer.ProgramId;
+ existingTimer.SeasonNumber = updatedTimer.SeasonNumber;
+ existingTimer.StartDate = updatedTimer.StartDate;
+ existingTimer.ShowId = updatedTimer.ShowId;
+ existingTimer.ProviderIds = updatedTimer.ProviderIds;
+ existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds;
+ }
+
+ public Task<IEnumerable<TimerInfo>> GetTimersAsync(CancellationToken cancellationToken)
+ {
+ var excludeStatues = new List<RecordingStatus>
+ {
+ RecordingStatus.Completed
+ };
+
+ var timers = _timerManager.GetAll()
+ .Where(i => !excludeStatues.Contains(i.Status));
+
+ return Task.FromResult(timers);
+ }
+
+ public Task<SeriesTimerInfo> GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null)
+ {
+ var config = _config.GetLiveTvConfiguration();
+
+ var defaults = new SeriesTimerInfo()
+ {
+ PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0),
+ PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0),
+ RecordAnyChannel = false,
+ RecordAnyTime = true,
+ RecordNewOnly = true,
+
+ Days = new List<DayOfWeek>
+ {
+ DayOfWeek.Sunday,
+ DayOfWeek.Monday,
+ DayOfWeek.Tuesday,
+ DayOfWeek.Wednesday,
+ DayOfWeek.Thursday,
+ DayOfWeek.Friday,
+ DayOfWeek.Saturday
+ }
+ };
+
+ if (program is not null)
+ {
+ defaults.SeriesId = program.SeriesId;
+ defaults.ProgramId = program.Id;
+ defaults.RecordNewOnly = !program.IsRepeat;
+ defaults.Name = program.Name;
+ }
+
+ defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly;
+ defaults.KeepUntil = KeepUntil.UntilDeleted;
+
+ return Task.FromResult(defaults);
+ }
+
+ public Task<IEnumerable<SeriesTimerInfo>> GetSeriesTimersAsync(CancellationToken cancellationToken)
+ {
+ return Task.FromResult((IEnumerable<SeriesTimerInfo>)_seriesTimerManager.GetAll());
+ }
+
+ public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
+ {
+ var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false);
+ var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase));
+
+ return await _listingsManager.GetProgramsAsync(channel, startDateUtc, endDateUtc, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public async Task<ILiveStream> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Streaming Channel {Id}", channelId);
+
+ var result = string.IsNullOrEmpty(streamId) ?
+ null :
+ currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase));
+
+ if (result is not null && result.EnableStreamSharing)
+ {
+ result.ConsumerCount++;
+
+ _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount);
+
+ return result;
+ }
+
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
+ {
+ try
+ {
+ result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+
+ var openedMediaSource = result.MediaSource;
+
+ result.OriginalStreamId = streamId;
+
+ _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId);
+
+ return result;
+ }
+ catch (FileNotFoundException)
+ {
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+
+ throw new ResourceNotFoundException("Tuner not found.");
+ }
+
+ public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ {
+ throw new ArgumentNullException(nameof(channelId));
+ }
+
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
+ {
+ try
+ {
+ var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false);
+
+ if (sources.Count > 0)
+ {
+ return sources;
+ }
+ }
+ catch (NotImplementedException)
+ {
+ }
+ }
+
+ throw new NotImplementedException();
+ }
+
+ public Task CloseLiveStream(string id, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ public Task ResetTuner(string id, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ private async void OnTimerManagerTimerFired(object sender, GenericEventArgs<TimerInfo> e)
+ {
+ var timer = e.Argument;
+
+ _logger.LogInformation("Recording timer fired for {0}.", timer.Name);
+
+ try
+ {
+ var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds);
+ if (recordingEndDate <= DateTime.UtcNow)
+ {
+ _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id);
+ _timerManager.Delete(timer);
+ return;
+ }
+
+ var activeRecordingInfo = new ActiveRecordingInfo
+ {
+ CancellationTokenSource = new CancellationTokenSource(),
+ Timer = timer,
+ Id = timer.Id
+ };
+
+ if (_recordingsManager.GetActiveRecordingPath(timer.Id) is not null)
+ {
+ _logger.LogInformation("Skipping RecordStream because it's already in progress.");
+ return;
+ }
+
+ LiveTvProgram programInfo = null;
+ if (!string.IsNullOrWhiteSpace(timer.ProgramId))
+ {
+ programInfo = GetProgramInfoFromCache(timer);
+ }
+
+ if (programInfo is null)
+ {
+ _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId);
+ programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate);
+ }
+
+ if (programInfo is not null)
+ {
+ CopyProgramInfoToTimerInfo(programInfo, timer);
+ }
+
+ await _recordingsManager.RecordStream(activeRecordingInfo, GetLiveTvChannel(timer), recordingEndDate)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error recording stream");
+ }
+ }
+
+ private BaseItem GetLiveTvChannel(TimerInfo timer)
+ {
+ var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId);
+ return _libraryManager.GetItemById(internalChannelId);
+ }
+
+ private LiveTvProgram GetProgramInfoFromCache(string programId)
+ {
+ var query = new InternalItemsQuery
+ {
+ ItemIds = [_tvDtoService.GetInternalProgramId(programId)],
+ Limit = 1,
+ DtoOptions = new DtoOptions()
+ };
+
+ return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault();
+ }
+
+ private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer)
+ {
+ return GetProgramInfoFromCache(timer.ProgramId);
+ }
+
+ private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc)
+ {
+ var query = new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
+ Limit = 1,
+ DtoOptions = new DtoOptions(true)
+ {
+ EnableImages = false
+ },
+ MinStartDate = startDateUtc.AddMinutes(-3),
+ MaxStartDate = startDateUtc.AddMinutes(3),
+ OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }
+ };
+
+ if (!string.IsNullOrWhiteSpace(channelId))
+ {
+ query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, channelId)];
+ }
+
+ return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault();
+ }
+
+ private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer)
+ {
+ if (timer.IsManual)
+ {
+ return false;
+ }
+
+ if (!seriesTimer.RecordAnyTime
+ && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks)
+ {
+ return true;
+ }
+
+ if (seriesTimer.RecordNewOnly && timer.IsRepeat)
+ {
+ return true;
+ }
+
+ if (!seriesTimer.RecordAnyChannel
+ && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer);
+ }
+
+ private void HandleDuplicateShowIds(List<TimerInfo> timers)
+ {
+ // sort showings by HD channels first, then by startDate, record earliest showing possible
+ foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1))
+ {
+ timer.Status = RecordingStatus.Cancelled;
+ _timerManager.Update(timer);
+ }
+ }
+
+ private void SearchForDuplicateShowIds(IEnumerable<TimerInfo> timers)
+ {
+ var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList();
+
+ foreach (var group in groups)
+ {
+ if (string.IsNullOrWhiteSpace(group.Key))
+ {
+ continue;
+ }
+
+ var groupTimers = group.ToList();
+
+ if (groupTimers.Count < 2)
+ {
+ continue;
+ }
+
+ // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856
+ if (group.Key.EndsWith("0000", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ HandleDuplicateShowIds(groupTimers);
+ }
+ }
+
+ private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers)
+ {
+ var allTimers = GetTimersForSeries(seriesTimer).ToList();
+
+ var enabledTimersForSeries = new List<TimerInfo>();
+ foreach (var timer in allTimers)
+ {
+ var existingTimer = _timerManager.GetTimer(timer.Id)
+ ?? (string.IsNullOrWhiteSpace(timer.ProgramId)
+ ? null
+ : _timerManager.GetTimerByProgramId(timer.ProgramId));
+
+ if (existingTimer is null)
+ {
+ if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
+ {
+ timer.Status = RecordingStatus.Cancelled;
+ }
+ else
+ {
+ enabledTimersForSeries.Add(timer);
+ }
+
+ _timerManager.Add(timer);
+
+ TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(timer));
+ }
+
+ // Only update if not currently active - test both new timer and existing in case Id's are different
+ // Id's could be different if the timer was created manually prior to series timer creation
+ else if (_recordingsManager.GetActiveRecordingPath(timer.Id) is null
+ && _recordingsManager.GetActiveRecordingPath(existingTimer.Id) is null)
+ {
+ UpdateExistingTimerWithNewMetadata(existingTimer, timer);
+
+ // Needed by ShouldCancelTimerForSeriesTimer
+ timer.IsManual = existingTimer.IsManual;
+
+ if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
+ {
+ existingTimer.Status = RecordingStatus.Cancelled;
+ }
+ else if (!existingTimer.IsManual)
+ {
+ existingTimer.Status = RecordingStatus.New;
+ }
+
+ if (existingTimer.Status != RecordingStatus.Cancelled)
+ {
+ enabledTimersForSeries.Add(existingTimer);
+ }
+
+ if (updateTimerSettings)
+ {
+ existingTimer.KeepUntil = seriesTimer.KeepUntil;
+ existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired;
+ existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired;
+ existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds;
+ existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds;
+ existingTimer.Priority = seriesTimer.Priority;
+ existingTimer.SeriesTimerId = seriesTimer.Id;
+ }
+
+ existingTimer.SeriesTimerId = seriesTimer.Id;
+ _timerManager.Update(existingTimer);
+ }
+ }
+
+ SearchForDuplicateShowIds(enabledTimersForSeries);
+
+ if (deleteInvalidTimers)
+ {
+ var allTimerIds = allTimers
+ .Select(i => i.Id)
+ .ToList();
+
+ var deleteStatuses = new[]
+ {
+ RecordingStatus.New
+ };
+
+ var deletes = _timerManager.GetAll()
+ .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase))
+ .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow)
+ .Where(i => deleteStatuses.Contains(i.Status))
+ .ToList();
+
+ foreach (var timer in deletes)
+ {
+ CancelTimerInternal(timer.Id, false, false);
+ }
+ }
+ }
+
+ private IEnumerable<TimerInfo> GetTimersForSeries(SeriesTimerInfo seriesTimer)
+ {
+ ArgumentNullException.ThrowIfNull(seriesTimer);
+
+ var query = new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
+ ExternalSeriesId = seriesTimer.SeriesId,
+ DtoOptions = new DtoOptions(true)
+ {
+ EnableImages = false
+ },
+ MinEndDate = DateTime.UtcNow
+ };
+
+ if (string.IsNullOrEmpty(seriesTimer.SeriesId))
+ {
+ query.Name = seriesTimer.Name;
+ }
+
+ if (!seriesTimer.RecordAnyChannel)
+ {
+ query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, seriesTimer.ChannelId)];
+ }
+
+ var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
+
+ return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().Select(i => CreateTimer(i, seriesTimer, tempChannelCache));
+ }
+
+ private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary<Guid, LiveTvChannel> tempChannelCache)
+ {
+ string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId;
+
+ if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.IsEmpty())
+ {
+ if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel))
+ {
+ channel = _libraryManager.GetItemList(
+ new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel },
+ ItemIds = new[] { parent.ChannelId },
+ DtoOptions = new DtoOptions()
+ }).FirstOrDefault() as LiveTvChannel;
+
+ if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId))
+ {
+ tempChannelCache[parent.ChannelId] = channel;
+ }
+ }
+
+ if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel))
+ {
+ channelId = channel.ExternalId;
+ }
+ }
+
+ var timer = new TimerInfo
+ {
+ ChannelId = channelId,
+ Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture),
+ StartDate = parent.StartDate,
+ EndDate = parent.EndDate.Value,
+ ProgramId = parent.ExternalId,
+ PrePaddingSeconds = seriesTimer.PrePaddingSeconds,
+ PostPaddingSeconds = seriesTimer.PostPaddingSeconds,
+ IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired,
+ IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired,
+ KeepUntil = seriesTimer.KeepUntil,
+ Priority = seriesTimer.Priority,
+ Name = parent.Name,
+ Overview = parent.Overview,
+ SeriesId = parent.ExternalSeriesId,
+ SeriesTimerId = seriesTimer.Id,
+ ShowId = parent.ShowId
+ };
+
+ CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache);
+
+ return timer;
+ }
+
+ private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo)
+ {
+ var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
+ CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache);
+ }
+
+ private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary<Guid, LiveTvChannel> tempChannelCache)
+ {
+ string channelId = null;
+
+ if (!programInfo.ChannelId.IsEmpty())
+ {
+ if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel))
+ {
+ channel = _libraryManager.GetItemList(
+ new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel },
+ ItemIds = new[] { programInfo.ChannelId },
+ DtoOptions = new DtoOptions()
+ }).FirstOrDefault() as LiveTvChannel;
+
+ if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId))
+ {
+ tempChannelCache[programInfo.ChannelId] = channel;
+ }
+ }
+
+ if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel))
+ {
+ channelId = channel.ExternalId;
+ }
+ }
+
+ timerInfo.Name = programInfo.Name;
+ timerInfo.StartDate = programInfo.StartDate;
+ timerInfo.EndDate = programInfo.EndDate.Value;
+
+ if (!string.IsNullOrWhiteSpace(channelId))
+ {
+ timerInfo.ChannelId = channelId;
+ }
+
+ timerInfo.SeasonNumber = programInfo.ParentIndexNumber;
+ timerInfo.EpisodeNumber = programInfo.IndexNumber;
+ timerInfo.IsMovie = programInfo.IsMovie;
+ timerInfo.ProductionYear = programInfo.ProductionYear;
+ timerInfo.EpisodeTitle = programInfo.EpisodeTitle;
+ timerInfo.OriginalAirDate = programInfo.PremiereDate;
+ timerInfo.IsProgramSeries = programInfo.IsSeries;
+
+ timerInfo.IsSeries = programInfo.IsSeries;
+
+ timerInfo.CommunityRating = programInfo.CommunityRating;
+ timerInfo.Overview = programInfo.Overview;
+ timerInfo.OfficialRating = programInfo.OfficialRating;
+ timerInfo.IsRepeat = programInfo.IsRepeat;
+ timerInfo.SeriesId = programInfo.ExternalSeriesId;
+ timerInfo.ProviderIds = programInfo.ProviderIds;
+ timerInfo.Tags = programInfo.Tags;
+
+ var seriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var providerId in timerInfo.ProviderIds)
+ {
+ const string Search = "Series";
+ if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase))
+ {
+ seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value;
+ }
+ }
+
+ timerInfo.SeriesProviderIds = seriesProviderIds;
+ }
+
+ private bool IsProgramAlreadyInLibrary(TimerInfo program)
+ {
+ if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle))
+ {
+ var seriesIds = _libraryManager.GetItemIds(
+ new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.Series },
+ Name = program.Name
+ }).ToArray();
+
+ if (seriesIds.Length == 0)
+ {
+ return false;
+ }
+
+ if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue)
+ {
+ var result = _libraryManager.GetItemIds(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.Episode },
+ ParentIndexNumber = program.SeasonNumber.Value,
+ IndexNumber = program.EpisodeNumber.Value,
+ AncestorIds = seriesIds,
+ IsVirtualItem = false,
+ Limit = 1
+ });
+
+ if (result.Count > 0)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
deleted file mode 100644
index 39f334184..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
+++ /dev/null
@@ -1,2540 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-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.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 MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Extensions;
-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.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;
-
- private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider;
- private readonly TimerManager _timerProvider;
-
- private readonly ITunerHostManager _tunerHostManager;
- private readonly IFileSystem _fileSystem;
-
- private readonly ILibraryMonitor _libraryMonitor;
- private readonly ILibraryManager _libraryManager;
- private readonly IProviderManager _providerManager;
- private readonly IMediaEncoder _mediaEncoder;
- private readonly IMediaSourceManager _mediaSourceManager;
- private readonly IStreamHelper _streamHelper;
- private readonly LiveTvDtoService _tvDtoService;
- private readonly IListingsProvider[] _listingsProviders;
-
- private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings =
- new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase);
-
- private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels =
- new ConcurrentDictionary<string, EpgChannelData>(StringComparer.OrdinalIgnoreCase);
-
- private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1);
-
- private bool _disposed;
-
- public EmbyTV(
- IStreamHelper streamHelper,
- IMediaSourceManager mediaSourceManager,
- ILogger<EmbyTV> logger,
- IHttpClientFactory httpClientFactory,
- IServerConfigurationManager config,
- ITunerHostManager tunerHostManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager,
- ILibraryMonitor libraryMonitor,
- IProviderManager providerManager,
- IMediaEncoder mediaEncoder,
- LiveTvDtoService tvDtoService,
- IEnumerable<IListingsProvider> listingsProviders)
- {
- Current = this;
-
- _logger = logger;
- _httpClientFactory = httpClientFactory;
- _config = config;
- _fileSystem = fileSystem;
- _libraryManager = libraryManager;
- _libraryMonitor = libraryMonitor;
- _providerManager = providerManager;
- _mediaEncoder = mediaEncoder;
- _tvDtoService = tvDtoService;
- _tunerHostManager = tunerHostManager;
- _mediaSourceManager = mediaSourceManager;
- _streamHelper = streamHelper;
- _listingsProviders = listingsProviders.ToArray();
-
- _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json"));
- _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json"));
- _timerProvider.TimerFired += OnTimerProviderTimerFired;
-
- _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
- }
-
- public event EventHandler<GenericEventArgs<TimerInfo>> TimerCreated;
-
- public event EventHandler<GenericEventArgs<string>> TimerCancelled;
-
- public static EmbyTV Current { get; private set; }
-
- /// <inheritdoc />
- public string Name => "Emby";
-
- public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv");
-
- /// <inheritdoc />
- public string HomePageUrl => "https://github.com/jellyfin/jellyfin";
-
- private string DefaultRecordingPath => Path.Combine(DataPath, "recordings");
-
- private string RecordingPath
- {
- get
- {
- var path = _config.GetLiveTvConfiguration().RecordingPath;
-
- return string.IsNullOrWhiteSpace(path)
- ? DefaultRecordingPath
- : path;
- }
- }
-
- private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
- {
- if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
- {
- await CreateRecordingFolders().ConfigureAwait(false);
- }
- }
-
- public Task Start()
- {
- _timerProvider.RestartTimers();
-
- return CreateRecordingFolders();
- }
-
- internal 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, StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- if (virtualFolder.Locations.Length == 1)
- {
- // remove entire virtual folder
- 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);
- }
- }
-
- public async Task RefreshSeriesTimers(CancellationToken cancellationToken)
- {
- var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
-
- foreach (var timer in seriesTimers)
- {
- UpdateTimersForSeriesTimer(timer, false, true);
- }
- }
-
- public async Task RefreshTimers(CancellationToken cancellationToken)
- {
- var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false);
-
- var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
-
- foreach (var timer in timers)
- {
- if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id))
- {
- OnTimerOutOfDate(timer);
- continue;
- }
-
- if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId))
- {
- continue;
- }
-
- var program = GetProgramInfoFromCache(timer);
- if (program is null)
- {
- OnTimerOutOfDate(timer);
- continue;
- }
-
- CopyProgramInfoToTimerInfo(program, timer, tempChannelCache);
- _timerProvider.Update(timer);
- }
- }
-
- private void OnTimerOutOfDate(TimerInfo timer)
- {
- _timerProvider.Delete(timer);
- }
-
- private async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken)
- {
- var list = new List<ChannelInfo>();
-
- foreach (var hostInstance in _tunerHostManager.TunerHosts)
- {
- try
- {
- var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false);
-
- list.AddRange(channels);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error getting channels");
- }
- }
-
- foreach (var provider in GetListingProviders())
- {
- var enabledChannels = list
- .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId))
- .ToList();
-
- if (enabledChannels.Count > 0)
- {
- try
- {
- await AddMetadata(provider.Item1, provider.Item2, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false);
- }
- catch (NotSupportedException)
- {
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error adding metadata");
- }
- }
- }
-
- return list;
- }
-
- private async Task AddMetadata(
- IListingsProvider provider,
- ListingsProviderInfo info,
- IEnumerable<ChannelInfo> tunerChannels,
- bool enableCache,
- CancellationToken cancellationToken)
- {
- var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false);
-
- foreach (var tunerChannel in tunerChannels)
- {
- var epgChannel = GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels);
-
- if (epgChannel is not null)
- {
- if (!string.IsNullOrWhiteSpace(epgChannel.Name))
- {
- // tunerChannel.Name = epgChannel.Name;
- }
-
- if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl))
- {
- tunerChannel.ImageUrl = epgChannel.ImageUrl;
- }
- }
- }
- }
-
- private async Task<EpgChannelData> GetEpgChannels(
- IListingsProvider provider,
- ListingsProviderInfo info,
- bool enableCache,
- CancellationToken cancellationToken)
- {
- if (!enableCache || !_epgChannels.TryGetValue(info.Id, out var result))
- {
- var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false);
-
- foreach (var channel in channels)
- {
- _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id);
- }
-
- result = new EpgChannelData(channels);
- _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result);
- }
-
- return result;
- }
-
- private async Task<ChannelInfo> GetEpgChannelFromTunerChannel(IListingsProvider provider, ListingsProviderInfo info, ChannelInfo tunerChannel, CancellationToken cancellationToken)
- {
- var epgChannels = await GetEpgChannels(provider, info, true, cancellationToken).ConfigureAwait(false);
-
- return GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels);
- }
-
- private static string GetMappedChannel(string channelId, NameValuePair[] mappings)
- {
- foreach (NameValuePair mapping in mappings)
- {
- if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase))
- {
- return mapping.Value;
- }
- }
-
- return channelId;
- }
-
- internal ChannelInfo GetEpgChannelFromTunerChannel(NameValuePair[] mappings, ChannelInfo tunerChannel, List<ChannelInfo> epgChannels)
- {
- return GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(epgChannels));
- }
-
- private ChannelInfo GetEpgChannelFromTunerChannel(ListingsProviderInfo info, ChannelInfo tunerChannel, EpgChannelData epgChannels)
- {
- return GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels);
- }
-
- private ChannelInfo GetEpgChannelFromTunerChannel(
- NameValuePair[] mappings,
- ChannelInfo tunerChannel,
- EpgChannelData epgChannelData)
- {
- if (!string.IsNullOrWhiteSpace(tunerChannel.Id))
- {
- var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings);
-
- if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
- {
- mappedTunerChannelId = tunerChannel.Id;
- }
-
- var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
-
- if (channel is not null)
- {
- return channel;
- }
- }
-
- if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId))
- {
- var tunerChannelId = tunerChannel.TunerChannelId;
- if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase))
- {
- tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I');
- }
-
- var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings);
-
- if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
- {
- mappedTunerChannelId = tunerChannelId;
- }
-
- var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
-
- if (channel is not null)
- {
- return channel;
- }
- }
-
- if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
- {
- var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings);
-
- if (string.IsNullOrWhiteSpace(tunerChannelNumber))
- {
- tunerChannelNumber = tunerChannel.Number;
- }
-
- var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber);
-
- if (channel is not null)
- {
- return channel;
- }
- }
-
- if (!string.IsNullOrWhiteSpace(tunerChannel.Name))
- {
- var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name);
-
- var channel = epgChannelData.GetChannelByName(normalizedName);
-
- if (channel is not null)
- {
- return channel;
- }
- }
-
- return null;
- }
-
- public async Task<List<ChannelInfo>> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken)
- {
- var list = new List<ChannelInfo>();
-
- foreach (var hostInstance in _tunerHostManager.TunerHosts)
- {
- try
- {
- var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false);
-
- list.AddRange(channels);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error getting channels");
- }
- }
-
- return list
- .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId))
- .ToList();
- }
-
- public Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken)
- {
- return GetChannelsAsync(false, cancellationToken);
- }
-
- public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken)
- {
- var timers = _timerProvider
- .GetAll()
- .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- foreach (var timer in timers)
- {
- CancelTimerInternal(timer.Id, true, true);
- }
-
- var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase));
- if (remove is not null)
- {
- _seriesTimerProvider.Delete(remove);
- }
-
- return Task.CompletedTask;
- }
-
- private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation)
- {
- var timer = _timerProvider.GetTimer(timerId);
- if (timer is not null)
- {
- var statusChanging = timer.Status != RecordingStatus.Cancelled;
- timer.Status = RecordingStatus.Cancelled;
-
- if (isManualCancellation)
- {
- timer.IsManual = true;
- }
-
- if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled)
- {
- _timerProvider.Delete(timer);
- }
- else
- {
- _timerProvider.AddOrUpdate(timer, false);
- }
-
- if (statusChanging && TimerCancelled is not null)
- {
- TimerCancelled(this, new GenericEventArgs<string>(timerId));
- }
- }
-
- if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo))
- {
- activeRecordingInfo.Timer = timer;
- activeRecordingInfo.CancellationTokenSource.Cancel();
- }
- }
-
- public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken)
- {
- CancelTimerInternal(timerId, false, true);
- return Task.CompletedTask;
- }
-
- public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
- {
- throw new NotImplementedException();
- }
-
- public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken)
- {
- throw new NotImplementedException();
- }
-
- public Task<string> CreateTimer(TimerInfo info, CancellationToken cancellationToken)
- {
- var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ?
- null :
- _timerProvider.GetTimerByProgramId(info.ProgramId);
-
- if (existingTimer is not null)
- {
- if (existingTimer.Status == RecordingStatus.Cancelled
- || existingTimer.Status == RecordingStatus.Completed)
- {
- existingTimer.Status = RecordingStatus.New;
- existingTimer.IsManual = true;
- _timerProvider.Update(existingTimer);
- return Task.FromResult(existingTimer.Id);
- }
-
- throw new ArgumentException("A scheduled recording already exists for this program.");
- }
-
- info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
-
- LiveTvProgram programInfo = null;
-
- if (!string.IsNullOrWhiteSpace(info.ProgramId))
- {
- programInfo = GetProgramInfoFromCache(info);
- }
-
- if (programInfo is null)
- {
- _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId);
- programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate);
- }
-
- if (programInfo is not null)
- {
- CopyProgramInfoToTimerInfo(programInfo, info);
- }
-
- info.IsManual = true;
- _timerProvider.Add(info);
-
- TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(info));
-
- return Task.FromResult(info.Id);
- }
-
- public async Task<string> CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken)
- {
- info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
-
- // populate info.seriesID
- var program = GetProgramInfoFromCache(info.ProgramId);
-
- if (program is not null)
- {
- info.SeriesId = program.ExternalSeriesId;
- }
- else
- {
- throw new InvalidOperationException("SeriesId for program not found");
- }
-
- // If any timers have already been manually created, make sure they don't get cancelled
- var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false))
- .Where(i =>
- {
- if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId))
- {
- return true;
- }
-
- if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId))
- {
- return true;
- }
-
- return false;
- })
- .ToList();
-
- _seriesTimerProvider.Add(info);
-
- foreach (var timer in existingTimers)
- {
- timer.SeriesTimerId = info.Id;
- timer.IsManual = true;
-
- _timerProvider.AddOrUpdate(timer, false);
- }
-
- UpdateTimersForSeriesTimer(info, true, false);
-
- return info.Id;
- }
-
- public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
- {
- var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
-
- if (instance is not null)
- {
- instance.ChannelId = info.ChannelId;
- instance.Days = info.Days;
- instance.EndDate = info.EndDate;
- instance.IsPostPaddingRequired = info.IsPostPaddingRequired;
- instance.IsPrePaddingRequired = info.IsPrePaddingRequired;
- instance.PostPaddingSeconds = info.PostPaddingSeconds;
- instance.PrePaddingSeconds = info.PrePaddingSeconds;
- instance.Priority = info.Priority;
- instance.RecordAnyChannel = info.RecordAnyChannel;
- instance.RecordAnyTime = info.RecordAnyTime;
- instance.RecordNewOnly = info.RecordNewOnly;
- instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary;
- instance.KeepUpTo = info.KeepUpTo;
- instance.KeepUntil = info.KeepUntil;
- instance.StartDate = info.StartDate;
-
- _seriesTimerProvider.Update(instance);
-
- UpdateTimersForSeriesTimer(instance, true, true);
- }
-
- return Task.CompletedTask;
- }
-
- public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken)
- {
- var existingTimer = _timerProvider.GetTimer(updatedTimer.Id);
-
- if (existingTimer is null)
- {
- throw new ResourceNotFoundException();
- }
-
- // Only update if not currently active
- if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _))
- {
- existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds;
- existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds;
- existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired;
- existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired;
-
- _timerProvider.Update(existingTimer);
- }
-
- return Task.CompletedTask;
- }
-
- private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer)
- {
- // Update the program info but retain the status
- existingTimer.ChannelId = updatedTimer.ChannelId;
- existingTimer.CommunityRating = updatedTimer.CommunityRating;
- existingTimer.EndDate = updatedTimer.EndDate;
- existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber;
- existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle;
- existingTimer.Genres = updatedTimer.Genres;
- existingTimer.IsMovie = updatedTimer.IsMovie;
- existingTimer.IsSeries = updatedTimer.IsSeries;
- existingTimer.Tags = updatedTimer.Tags;
- existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries;
- existingTimer.IsRepeat = updatedTimer.IsRepeat;
- existingTimer.Name = updatedTimer.Name;
- existingTimer.OfficialRating = updatedTimer.OfficialRating;
- existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate;
- existingTimer.Overview = updatedTimer.Overview;
- existingTimer.ProductionYear = updatedTimer.ProductionYear;
- existingTimer.ProgramId = updatedTimer.ProgramId;
- existingTimer.SeasonNumber = updatedTimer.SeasonNumber;
- existingTimer.StartDate = updatedTimer.StartDate;
- existingTimer.ShowId = updatedTimer.ShowId;
- existingTimer.ProviderIds = updatedTimer.ProviderIds;
- existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds;
- }
-
- public string GetActiveRecordingPath(string id)
- {
- if (_activeRecordings.TryGetValue(id, out var info))
- {
- return info.Path;
- }
-
- return null;
- }
-
- 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)
- {
- var timer = recordingInfo.Timer;
- if (timer.Status != RecordingStatus.InProgress)
- {
- return null;
- }
-
- return recordingInfo;
- }
- }
-
- return null;
- }
-
- public Task<IEnumerable<TimerInfo>> GetTimersAsync(CancellationToken cancellationToken)
- {
- var excludeStatues = new List<RecordingStatus>
- {
- RecordingStatus.Completed
- };
-
- var timers = _timerProvider.GetAll()
- .Where(i => !excludeStatues.Contains(i.Status));
-
- return Task.FromResult(timers);
- }
-
- public Task<SeriesTimerInfo> GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null)
- {
- var config = _config.GetLiveTvConfiguration();
-
- var defaults = new SeriesTimerInfo()
- {
- PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0),
- PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0),
- RecordAnyChannel = false,
- RecordAnyTime = true,
- RecordNewOnly = true,
-
- Days = new List<DayOfWeek>
- {
- DayOfWeek.Sunday,
- DayOfWeek.Monday,
- DayOfWeek.Tuesday,
- DayOfWeek.Wednesday,
- DayOfWeek.Thursday,
- DayOfWeek.Friday,
- DayOfWeek.Saturday
- }
- };
-
- if (program is not null)
- {
- defaults.SeriesId = program.SeriesId;
- defaults.ProgramId = program.Id;
- defaults.RecordNewOnly = !program.IsRepeat;
- defaults.Name = program.Name;
- }
-
- defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly;
- defaults.KeepUntil = KeepUntil.UntilDeleted;
-
- return Task.FromResult(defaults);
- }
-
- public Task<IEnumerable<SeriesTimerInfo>> GetSeriesTimersAsync(CancellationToken cancellationToken)
- {
- return Task.FromResult((IEnumerable<SeriesTimerInfo>)_seriesTimerProvider.GetAll());
- }
-
- private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId)
- {
- if (info.EnableAllTuners)
- {
- return true;
- }
-
- if (string.IsNullOrWhiteSpace(tunerHostId))
- {
- throw new ArgumentNullException(nameof(tunerHostId));
- }
-
- return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase);
- }
-
- public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
- {
- var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false);
- var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase));
-
- foreach (var provider in GetListingProviders())
- {
- if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId))
- {
- _logger.LogDebug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty);
- continue;
- }
-
- _logger.LogDebug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty);
-
- var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false);
-
- if (epgChannel is null)
- {
- _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty);
- continue;
- }
-
- List<ProgramInfo> programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken)
- .ConfigureAwait(false)).ToList();
-
- // Replace the value that came from the provider with a normalized value
- foreach (var program in programs)
- {
- program.ChannelId = channelId;
-
- program.Id += "_" + channelId;
- }
-
- if (programs.Count > 0)
- {
- return programs;
- }
- }
-
- return Enumerable.Empty<ProgramInfo>();
- }
-
- private List<Tuple<IListingsProvider, ListingsProviderInfo>> GetListingProviders()
- {
- return _config.GetLiveTvConfiguration().ListingProviders
- .Select(i =>
- {
- var provider = _listingsProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase));
-
- return provider is null ? null : new Tuple<IListingsProvider, ListingsProviderInfo>(provider, i);
- })
- .Where(i => i is not null)
- .ToList();
- }
-
- public Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken)
- {
- throw new NotImplementedException();
- }
-
- public async Task<ILiveStream> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
- {
- _logger.LogInformation("Streaming Channel {Id}", channelId);
-
- var result = string.IsNullOrEmpty(streamId) ?
- null :
- currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase));
-
- if (result is not null && result.EnableStreamSharing)
- {
- result.ConsumerCount++;
-
- _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount);
-
- return result;
- }
-
- foreach (var hostInstance in _tunerHostManager.TunerHosts)
- {
- try
- {
- result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
-
- var openedMediaSource = result.MediaSource;
-
- result.OriginalStreamId = streamId;
-
- _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId);
-
- return result;
- }
- catch (FileNotFoundException)
- {
- }
- catch (OperationCanceledException)
- {
- }
- }
-
- throw new ResourceNotFoundException("Tuner not found.");
- }
-
- public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken)
- {
- if (string.IsNullOrWhiteSpace(channelId))
- {
- throw new ArgumentNullException(nameof(channelId));
- }
-
- foreach (var hostInstance in _tunerHostManager.TunerHosts)
- {
- try
- {
- var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false);
-
- if (sources.Count > 0)
- {
- return sources;
- }
- }
- catch (NotImplementedException)
- {
- }
- }
-
- throw new NotImplementedException();
- }
-
- public Task CloseLiveStream(string id, CancellationToken cancellationToken)
- {
- return Task.CompletedTask;
- }
-
- public Task ResetTuner(string id, CancellationToken cancellationToken)
- {
- return Task.CompletedTask;
- }
-
- private async void OnTimerProviderTimerFired(object sender, GenericEventArgs<TimerInfo> e)
- {
- var timer = e.Argument;
-
- _logger.LogInformation("Recording timer fired for {0}.", timer.Name);
-
- try
- {
- var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds);
-
- if (recordingEndDate <= DateTime.UtcNow)
- {
- _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id);
- OnTimerOutOfDate(timer);
- return;
- }
-
- var activeRecordingInfo = new ActiveRecordingInfo
- {
- CancellationTokenSource = new CancellationTokenSource(),
- Timer = timer,
- Id = timer.Id
- };
-
- if (!_activeRecordings.ContainsKey(timer.Id))
- {
- await RecordStream(timer, recordingEndDate, activeRecordingInfo).ConfigureAwait(false);
- }
- else
- {
- _logger.LogInformation("Skipping RecordStream because it's already in progress.");
- }
- }
- catch (OperationCanceledException)
- {
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error recording stream");
- }
- }
-
- private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath)
- {
- var recordPath = RecordingPath;
- var config = _config.GetLiveTvConfiguration();
- seriesPath = null;
-
- if (timer.IsProgramSeries)
- {
- var customRecordingPath = config.SeriesRecordingPath;
- var allowSubfolder = true;
- if (!string.IsNullOrWhiteSpace(customRecordingPath))
- {
- allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase);
- recordPath = customRecordingPath;
- }
-
- if (allowSubfolder && config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "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.
- recordPath = Path.Combine(recordPath, folderName);
-
- seriesPath = recordPath;
-
- if (timer.SeasonNumber.HasValue)
- {
- folderName = string.Format(
- CultureInfo.InvariantCulture,
- "Season {0}",
- timer.SeasonNumber.Value);
- recordPath = Path.Combine(recordPath, folderName);
- }
- }
- else if (timer.IsMovie)
- {
- var customRecordingPath = config.MovieRecordingPath;
- var allowSubfolder = true;
- if (!string.IsNullOrWhiteSpace(customRecordingPath))
- {
- allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase);
- recordPath = customRecordingPath;
- }
-
- if (allowSubfolder && config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "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();
-
- recordPath = Path.Combine(recordPath, folderName);
- }
- else if (timer.IsKids)
- {
- if (config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "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();
-
- recordPath = Path.Combine(recordPath, folderName);
- }
- else if (timer.IsSports)
- {
- if (config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "Sports");
- }
-
- recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim());
- }
- else
- {
- if (config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "Other");
- }
-
- recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim());
- }
-
- var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts";
-
- return Path.Combine(recordPath, recordingFileName);
- }
-
- private BaseItem GetLiveTvChannel(TimerInfo timer)
- {
- var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId);
- return _libraryManager.GetItemById(internalChannelId);
- }
-
- private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo)
- {
- ArgumentNullException.ThrowIfNull(timer);
-
- LiveTvProgram programInfo = null;
-
- if (!string.IsNullOrWhiteSpace(timer.ProgramId))
- {
- programInfo = GetProgramInfoFromCache(timer);
- }
-
- if (programInfo is null)
- {
- _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId);
- programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate);
- }
-
- if (programInfo is not null)
- {
- CopyProgramInfoToTimerInfo(programInfo, timer);
- }
-
- var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
- var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath);
-
- var channelItem = GetLiveTvChannel(timer);
-
- string liveStreamId = null;
- RecordingStatus recordingStatus;
- try
- {
- var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, 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 = channelItem.Id,
- OpenToken = mediaStreamInfo.OpenToken
- },
- CancellationToken.None).ConfigureAwait(false);
-
- mediaStreamInfo = liveStreamResponse.Item1.MediaSource;
- liveStreamId = mediaStreamInfo.LiveStreamId;
- directStreamProvider = liveStreamResponse.Item2;
- }
-
- using var recorder = GetRecorder(mediaStreamInfo);
-
- recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath);
- recordPath = EnsureFileUnique(recordPath, timer.Id);
-
- _libraryMonitor.ReportFileSystemChangeBeginning(recordPath);
-
- var duration = recordingEndDate - DateTime.UtcNow;
-
- _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture));
-
- _logger.LogInformation("Writing file to: {Path}", recordPath);
-
- Action onStarted = async () =>
- {
- activeRecordingInfo.Path = recordPath;
-
- _activeRecordings.TryAdd(timer.Id, activeRecordingInfo);
-
- timer.Status = RecordingStatus.InProgress;
- _timerProvider.AddOrUpdate(timer, false);
-
- await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false);
-
- await CreateRecordingFolders().ConfigureAwait(false);
-
- TriggerRefresh(recordPath);
- await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
- };
-
- await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
-
- recordingStatus = RecordingStatus.Completed;
- _logger.LogInformation("Recording completed: {RecordPath}", recordPath);
- }
- catch (OperationCanceledException)
- {
- _logger.LogInformation("Recording stopped: {RecordPath}", recordPath);
- recordingStatus = RecordingStatus.Completed;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error recording to {RecordPath}", recordPath);
- 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(recordPath);
-
- TriggerRefresh(recordPath);
- _libraryMonitor.ReportFileSystemChangeComplete(recordPath, 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++;
- _timerProvider.AddOrUpdate(timer);
- }
- else if (File.Exists(recordPath))
- {
- timer.RecordingPath = recordPath;
- timer.Status = RecordingStatus.Completed;
- _timerProvider.AddOrUpdate(timer, false);
- OnSuccessfulRecording(timer, recordPath);
- }
- else
- {
- _timerProvider.Delete(timer);
- }
- }
-
- private async Task<RemoteSearchResult> FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken)
- {
- if (timer.IsSeries)
- {
- if (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();
- }
-
- return null;
- }
-
- 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 not null)
- {
- _logger.LogInformation("Refreshing recording parent {Path}", item.Path);
-
- _providerManager.QueueRefresh(
- item.Id,
- new MetadataRefreshOptions(new DirectoryService(_fileSystem))
- {
- RefreshPaths = new string[]
- {
- 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)
- {
- if (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))
- {
- return;
- }
-
- if (string.IsNullOrWhiteSpace(seriesPath))
- {
- return;
- }
-
- var seriesTimerId = timer.SeriesTimerId;
- var seriesTimer = _seriesTimerProvider.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 = _timerProvider.GetAll()
- .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath))
- .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase))
- .OrderByDescending(i => i.EndDate)
- .Where(i => File.Exists(i.RecordingPath))
- .Skip(seriesTimer.KeepUpTo - 1)
- .ToList();
-
- DeleteLibraryItemsForTimers(timersToDelete);
-
- if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries)
- {
- return;
- }
-
- var episodesToDelete = librarySeries.GetItemList(
- new InternalItemsQuery
- {
- OrderBy = new[] { (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)
- .ToList();
-
- 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);
- }
-
- _timerProvider.Delete(timer);
- }
-
- private string EnsureFileUnique(string path, string timerId)
- {
- var originalPath = path;
- var index = 1;
-
- while (FileExists(path, timerId))
- {
- var parent = Path.GetDirectoryName(originalPath);
- var name = Path.GetFileNameWithoutExtension(originalPath);
- name += " - " + index.ToString(CultureInfo.InvariantCulture);
-
- path = Path.ChangeExtension(Path.Combine(parent, name), Path.GetExtension(originalPath));
- index++;
- }
-
- return path;
- }
-
- private bool FileExists(string path, string timerId)
- {
- if (File.Exists(path))
- {
- return true;
- }
-
- return _activeRecordings
- .Any(i => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase));
- }
-
- private IRecorder GetRecorder(MediaSourceInfo mediaSource)
- {
- if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http))
- {
- return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config);
- }
-
- return new DirectRecorder(_logger, _httpClientFactory, _streamHelper);
- }
-
- private void OnSuccessfulRecording(TimerInfo timer, string path)
- {
- PostProcessRecording(timer, path);
- }
-
- private void PostProcessRecording(TimerInfo timer, string path)
- {
- var options = _config.GetLiveTvConfiguration();
- if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor))
- {
- return;
- }
-
- try
- {
- var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments),
- CreateNoWindow = true,
- ErrorDialog = false,
- FileName = options.RecordingPostProcessor,
- WindowStyle = ProcessWindowStyle.Hidden,
- UseShellExecute = false
- },
- EnableRaisingEvents = true
- };
-
- _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- process.Exited += OnProcessExited;
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error running recording post processor");
- }
- }
-
- private static string GetPostProcessArguments(string path, string arguments)
- {
- return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase);
- }
-
- private void OnProcessExited(object sender, EventArgs e)
- {
- using (var process = (Process)sender)
- {
- _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode);
- }
- }
-
- 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
- {
- ItemIds = [_tvDtoService.GetInternalProgramId(programId)],
- Limit = 1,
- DtoOptions = new DtoOptions()
- };
-
- return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault();
- }
-
- private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer)
- {
- return GetProgramInfoFromCache(timer.ProgramId);
- }
-
- private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc)
- {
- var query = new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
- Limit = 1,
- DtoOptions = new DtoOptions(true)
- {
- EnableImages = false
- },
- MinStartDate = startDateUtc.AddMinutes(-3),
- MaxStartDate = startDateUtc.AddMinutes(3),
- OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }
- };
-
- if (!string.IsNullOrWhiteSpace(channelId))
- {
- query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, channelId)];
- }
-
- return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault();
- }
-
- private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer)
- {
- if (timer.IsManual)
- {
- return false;
- }
-
- if (!seriesTimer.RecordAnyTime
- && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks)
- {
- return true;
- }
-
- if (seriesTimer.RecordNewOnly && timer.IsRepeat)
- {
- return true;
- }
-
- if (!seriesTimer.RecordAnyChannel
- && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
- return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer);
- }
-
- private void HandleDuplicateShowIds(List<TimerInfo> timers)
- {
- // sort showings by HD channels first, then by startDate, record earliest showing possible
- foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1))
- {
- timer.Status = RecordingStatus.Cancelled;
- _timerProvider.Update(timer);
- }
- }
-
- private void SearchForDuplicateShowIds(IEnumerable<TimerInfo> timers)
- {
- var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList();
-
- foreach (var group in groups)
- {
- if (string.IsNullOrWhiteSpace(group.Key))
- {
- continue;
- }
-
- var groupTimers = group.ToList();
-
- if (groupTimers.Count < 2)
- {
- continue;
- }
-
- // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856
- if (group.Key.EndsWith("0000", StringComparison.Ordinal))
- {
- continue;
- }
-
- HandleDuplicateShowIds(groupTimers);
- }
- }
-
- private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers)
- {
- var allTimers = GetTimersForSeries(seriesTimer).ToList();
-
- var enabledTimersForSeries = new List<TimerInfo>();
- foreach (var timer in allTimers)
- {
- var existingTimer = _timerProvider.GetTimer(timer.Id)
- ?? (string.IsNullOrWhiteSpace(timer.ProgramId)
- ? null
- : _timerProvider.GetTimerByProgramId(timer.ProgramId));
-
- if (existingTimer is null)
- {
- if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
- {
- timer.Status = RecordingStatus.Cancelled;
- }
- else
- {
- enabledTimersForSeries.Add(timer);
- }
-
- _timerProvider.Add(timer);
-
- TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(timer));
- }
-
- // Only update if not currently active - test both new timer and existing in case Id's are different
- // Id's could be different if the timer was created manually prior to series timer creation
- else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _))
- {
- UpdateExistingTimerWithNewMetadata(existingTimer, timer);
-
- // Needed by ShouldCancelTimerForSeriesTimer
- timer.IsManual = existingTimer.IsManual;
-
- if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
- {
- existingTimer.Status = RecordingStatus.Cancelled;
- }
- else if (!existingTimer.IsManual)
- {
- existingTimer.Status = RecordingStatus.New;
- }
-
- if (existingTimer.Status != RecordingStatus.Cancelled)
- {
- enabledTimersForSeries.Add(existingTimer);
- }
-
- if (updateTimerSettings)
- {
- existingTimer.KeepUntil = seriesTimer.KeepUntil;
- existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired;
- existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired;
- existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds;
- existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds;
- existingTimer.Priority = seriesTimer.Priority;
- existingTimer.SeriesTimerId = seriesTimer.Id;
- }
-
- existingTimer.SeriesTimerId = seriesTimer.Id;
- _timerProvider.Update(existingTimer);
- }
- }
-
- SearchForDuplicateShowIds(enabledTimersForSeries);
-
- if (deleteInvalidTimers)
- {
- var allTimerIds = allTimers
- .Select(i => i.Id)
- .ToList();
-
- var deleteStatuses = new[]
- {
- RecordingStatus.New
- };
-
- var deletes = _timerProvider.GetAll()
- .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase))
- .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow)
- .Where(i => deleteStatuses.Contains(i.Status))
- .ToList();
-
- foreach (var timer in deletes)
- {
- CancelTimerInternal(timer.Id, false, false);
- }
- }
- }
-
- private IEnumerable<TimerInfo> GetTimersForSeries(SeriesTimerInfo seriesTimer)
- {
- ArgumentNullException.ThrowIfNull(seriesTimer);
-
- var query = new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
- ExternalSeriesId = seriesTimer.SeriesId,
- DtoOptions = new DtoOptions(true)
- {
- EnableImages = false
- },
- MinEndDate = DateTime.UtcNow
- };
-
- if (string.IsNullOrEmpty(seriesTimer.SeriesId))
- {
- query.Name = seriesTimer.Name;
- }
-
- if (!seriesTimer.RecordAnyChannel)
- {
- query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, seriesTimer.ChannelId)];
- }
-
- var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
-
- return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().Select(i => CreateTimer(i, seriesTimer, tempChannelCache));
- }
-
- private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary<Guid, LiveTvChannel> tempChannelCache)
- {
- string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId;
-
- if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.IsEmpty())
- {
- if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel))
- {
- channel = _libraryManager.GetItemList(
- new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel },
- ItemIds = new[] { parent.ChannelId },
- DtoOptions = new DtoOptions()
- }).FirstOrDefault() as LiveTvChannel;
-
- if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId))
- {
- tempChannelCache[parent.ChannelId] = channel;
- }
- }
-
- if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel))
- {
- channelId = channel.ExternalId;
- }
- }
-
- var timer = new TimerInfo
- {
- ChannelId = channelId,
- Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture),
- StartDate = parent.StartDate,
- EndDate = parent.EndDate.Value,
- ProgramId = parent.ExternalId,
- PrePaddingSeconds = seriesTimer.PrePaddingSeconds,
- PostPaddingSeconds = seriesTimer.PostPaddingSeconds,
- IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired,
- IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired,
- KeepUntil = seriesTimer.KeepUntil,
- Priority = seriesTimer.Priority,
- Name = parent.Name,
- Overview = parent.Overview,
- SeriesId = parent.ExternalSeriesId,
- SeriesTimerId = seriesTimer.Id,
- ShowId = parent.ShowId
- };
-
- CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache);
-
- return timer;
- }
-
- private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo)
- {
- var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
- CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache);
- }
-
- private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary<Guid, LiveTvChannel> tempChannelCache)
- {
- string channelId = null;
-
- if (!programInfo.ChannelId.IsEmpty())
- {
- if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel))
- {
- channel = _libraryManager.GetItemList(
- new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel },
- ItemIds = new[] { programInfo.ChannelId },
- DtoOptions = new DtoOptions()
- }).FirstOrDefault() as LiveTvChannel;
-
- if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId))
- {
- tempChannelCache[programInfo.ChannelId] = channel;
- }
- }
-
- if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel))
- {
- channelId = channel.ExternalId;
- }
- }
-
- timerInfo.Name = programInfo.Name;
- timerInfo.StartDate = programInfo.StartDate;
- timerInfo.EndDate = programInfo.EndDate.Value;
-
- if (!string.IsNullOrWhiteSpace(channelId))
- {
- timerInfo.ChannelId = channelId;
- }
-
- timerInfo.SeasonNumber = programInfo.ParentIndexNumber;
- timerInfo.EpisodeNumber = programInfo.IndexNumber;
- timerInfo.IsMovie = programInfo.IsMovie;
- timerInfo.ProductionYear = programInfo.ProductionYear;
- timerInfo.EpisodeTitle = programInfo.EpisodeTitle;
- timerInfo.OriginalAirDate = programInfo.PremiereDate;
- timerInfo.IsProgramSeries = programInfo.IsSeries;
-
- timerInfo.IsSeries = programInfo.IsSeries;
-
- timerInfo.CommunityRating = programInfo.CommunityRating;
- timerInfo.Overview = programInfo.Overview;
- timerInfo.OfficialRating = programInfo.OfficialRating;
- timerInfo.IsRepeat = programInfo.IsRepeat;
- timerInfo.SeriesId = programInfo.ExternalSeriesId;
- timerInfo.ProviderIds = programInfo.ProviderIds;
- timerInfo.Tags = programInfo.Tags;
-
- var seriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
-
- foreach (var providerId in timerInfo.ProviderIds)
- {
- const string Search = "Series";
- if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase))
- {
- seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value;
- }
- }
-
- timerInfo.SeriesProviderIds = seriesProviderIds;
- }
-
- private bool IsProgramAlreadyInLibrary(TimerInfo program)
- {
- if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle))
- {
- var seriesIds = _libraryManager.GetItemIds(
- new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.Series },
- Name = program.Name
- }).ToArray();
-
- if (seriesIds.Length == 0)
- {
- return false;
- }
-
- if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue)
- {
- var result = _libraryManager.GetItemIds(new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.Episode },
- ParentIndexNumber = program.SeasonNumber.Value,
- IndexNumber = program.EpisodeNumber.Value,
- AncestorIds = seriesIds,
- IsVirtualItem = false,
- Limit = 1
- });
-
- if (result.Count > 0)
- {
- return true;
- }
- }
- }
-
- return false;
- }
-
- /// <inheritdoc />
- public void Dispose()
- {
- if (_disposed)
- {
- return;
- }
-
- _recordingDeleteSemaphore.Dispose();
-
- foreach (var pair in _activeRecordings.ToList())
- {
- pair.Value.CancellationTokenSource.Cancel();
- }
-
- _disposed = true;
- }
-
- public IEnumerable<VirtualFolderInfo> GetRecordingFolders()
- {
- var defaultFolder = RecordingPath;
- var defaultName = "Recordings";
-
- if (Directory.Exists(defaultFolder))
- {
- yield return new VirtualFolderInfo
- {
- Locations = new string[] { defaultFolder },
- Name = defaultName
- };
- }
-
- var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath;
- if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath))
- {
- yield return new VirtualFolderInfo
- {
- Locations = new string[] { customPath },
- Name = "Recorded Movies",
- CollectionType = CollectionTypeOptions.Movies
- };
- }
-
- customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath;
- if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath))
- {
- yield return new VirtualFolderInfo
- {
- Locations = new string[] { customPath },
- Name = "Recorded Shows",
- CollectionType = CollectionTypeOptions.TvShows
- };
- }
- }
- }
-}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs
deleted file mode 100644
index dc15d53ff..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.LiveTv;
-using Microsoft.Extensions.Hosting;
-
-namespace Jellyfin.LiveTv.EmbyTV;
-
-/// <summary>
-/// <see cref="IHostedService"/> responsible for initializing Live TV.
-/// </summary>
-public sealed class LiveTvHost : IHostedService
-{
- private readonly EmbyTV _service;
-
- /// <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)
- {
- _service = services.OfType<EmbyTV>().First();
- }
-
- /// <inheritdoc />
- public Task StartAsync(CancellationToken cancellationToken) => _service.Start();
-
- /// <inheritdoc />
- public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
-}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs b/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs
deleted file mode 100644
index e8570f0e0..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Model.Configuration;
-
-namespace Jellyfin.LiveTv.EmbyTV
-{
- /// <summary>
- /// Class containing extension methods for working with the nfo configuration.
- /// </summary>
- public static class NfoConfigurationExtensions
- {
- /// <summary>
- /// Gets the nfo configuration.
- /// </summary>
- /// <param name="configurationManager">The configuration manager.</param>
- /// <returns>The nfo configuration.</returns>
- public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager)
- => configurationManager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata");
- }
-}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs
deleted file mode 100644
index 2ebe60b29..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs
+++ /dev/null
@@ -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);
- }
- }
-}
diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
index a07325ad1..73729c950 100644
--- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
+++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
@@ -1,6 +1,9 @@
using Jellyfin.LiveTv.Channels;
using Jellyfin.LiveTv.Guide;
+using Jellyfin.LiveTv.IO;
using Jellyfin.LiveTv.Listings;
+using Jellyfin.LiveTv.Recordings;
+using Jellyfin.LiveTv.Timers;
using Jellyfin.LiveTv.TunerHosts;
using Jellyfin.LiveTv.TunerHosts.HdHomerun;
using MediaBrowser.Controller.Channels;
@@ -22,13 +25,19 @@ 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<ILiveTvService, DefaultLiveTvService>();
services.AddSingleton<ITunerHost, HdHomerunHost>();
services.AddSingleton<ITunerHost, M3UTunerHost>();
services.AddSingleton<IListingsProvider, SchedulesDirect>();
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index 394fbbaea..093970c38 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -27,6 +27,8 @@ public class GuideManager : IGuideManager
private const string EtagKey = "ProgramEtag";
private const string ExternalServiceTag = "ExternalServiceId";
+ private static readonly ParallelOptions _cacheParallelOptions = new() { MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, 10) };
+
private readonly ILogger<GuideManager> _logger;
private readonly IConfigurationManager _config;
private readonly IFileSystem _fileSystem;
@@ -34,6 +36,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 +49,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 +59,7 @@ public class GuideManager : IGuideManager
ILibraryManager libraryManager,
ILiveTvManager liveTvManager,
ITunerHostManager tunerHostManager,
+ IRecordingsManager recordingsManager,
LiveTvDtoService tvDtoService)
{
_logger = logger;
@@ -64,6 +69,7 @@ public class GuideManager : IGuideManager
_libraryManager = libraryManager;
_liveTvManager = liveTvManager;
_tunerHostManager = tunerHostManager;
+ _recordingsManager = recordingsManager;
_tvDtoService = tvDtoService;
}
@@ -85,7 +91,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);
@@ -137,7 +143,7 @@ public class GuideManager : IGuideManager
CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken);
}
- var coreService = _liveTvManager.Services.OfType<EmbyTV.EmbyTV>().FirstOrDefault();
+ var coreService = _liveTvManager.Services.OfType<DefaultLiveTvService>().FirstOrDefault();
if (coreService is not null)
{
await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
@@ -205,6 +211,7 @@ public class GuideManager : IGuideManager
_logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays);
+ var maxCacheDate = DateTime.UtcNow.AddDays(2);
foreach (var currentChannel in list)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -259,6 +266,7 @@ public class GuideManager : IGuideManager
if (newPrograms.Count > 0)
{
_libraryManager.CreateItems(newPrograms, null, cancellationToken);
+ await PrecacheImages(newPrograms, maxCacheDate).ConfigureAwait(false);
}
if (updatedPrograms.Count > 0)
@@ -268,6 +276,7 @@ public class GuideManager : IGuideManager
currentChannel,
ItemUpdateType.MetadataImport,
cancellationToken).ConfigureAwait(false);
+ await PrecacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false);
}
currentChannel.IsMovie = isMovie;
@@ -704,4 +713,41 @@ public class GuideManager : IGuideManager
return (item, isNew, isUpdated);
}
+
+ private async Task PrecacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
+ {
+ await Parallel.ForEachAsync(
+ programs
+ .Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
+ .DistinctBy(p => p.Id),
+ _cacheParallelOptions,
+ async (program, cancellationToken) =>
+ {
+ for (var i = 0; i < program.ImageInfos.Length; i++)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ var imageInfo = program.ImageInfos[i];
+ if (!imageInfo.IsLocalFile)
+ {
+ try
+ {
+ program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
+ program,
+ imageInfo,
+ imageIndex: 0,
+ removeOnFailure: false)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Unable to precache {Url}", imageInfo.Path);
+ }
+ }
+ }
+ }).ConfigureAwait(false);
+ }
}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs b/src/Jellyfin.LiveTv/IO/DirectRecorder.cs
index 2a25218b6..c4ec6de40 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/DirectRecorder.cs
@@ -12,7 +12,7 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.IO
{
public sealed class DirectRecorder : IRecorder
{
diff --git a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
index 132a5fc51..ff00c8999 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
@@ -23,7 +23,7 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.IO
{
public class EncodedRecorder : IRecorder
{
diff --git a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs b/src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs
index 9d442e20c..394b9cf11 100644
--- a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs
+++ b/src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs
@@ -11,7 +11,7 @@ using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
-namespace Jellyfin.LiveTv
+namespace Jellyfin.LiveTv.IO
{
public sealed class ExclusiveLiveStream : ILiveStream
{
diff --git a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs b/src/Jellyfin.LiveTv/IO/IRecorder.cs
index 7ed42e263..ab4506414 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/IRecorder.cs
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.IO
{
public interface IRecorder : IDisposable
{
diff --git a/src/Jellyfin.LiveTv/StreamHelper.cs b/src/Jellyfin.LiveTv/IO/StreamHelper.cs
index e9644e95e..7947807ba 100644
--- a/src/Jellyfin.LiveTv/StreamHelper.cs
+++ b/src/Jellyfin.LiveTv/IO/StreamHelper.cs
@@ -7,7 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.IO;
-namespace Jellyfin.LiveTv
+namespace Jellyfin.LiveTv.IO
{
public class StreamHelper : IStreamHelper
{
diff --git a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs b/src/Jellyfin.LiveTv/Listings/EpgChannelData.cs
index 43d308c43..81437f791 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs
+++ b/src/Jellyfin.LiveTv/Listings/EpgChannelData.cs
@@ -4,7 +4,7 @@ using System;
using System.Collections.Generic;
using MediaBrowser.Controller.LiveTv;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Listings
{
internal class EpgChannelData
{
diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
new file mode 100644
index 000000000..87f47611e
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
@@ -0,0 +1,461 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.Configuration;
+using Jellyfin.LiveTv.Guide;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.Listings;
+
+/// <inheritdoc />
+public class ListingsManager : IListingsManager
+{
+ private readonly ILogger<ListingsManager> _logger;
+ private readonly IConfigurationManager _config;
+ private readonly ITaskManager _taskManager;
+ private readonly ITunerHostManager _tunerHostManager;
+ private readonly IListingsProvider[] _listingsProviders;
+
+ private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels = new(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ListingsManager"/> class.
+ /// </summary>
+ /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
+ /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
+ /// <param name="taskManager">The <see cref="ITaskManager"/>.</param>
+ /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
+ /// <param name="listingsProviders">The <see cref="IListingsProvider"/>.</param>
+ public ListingsManager(
+ ILogger<ListingsManager> logger,
+ IConfigurationManager config,
+ ITaskManager taskManager,
+ ITunerHostManager tunerHostManager,
+ IEnumerable<IListingsProvider> listingsProviders)
+ {
+ _logger = logger;
+ _config = config;
+ _taskManager = taskManager;
+ _tunerHostManager = tunerHostManager;
+ _listingsProviders = listingsProviders.ToArray();
+ }
+
+ /// <inheritdoc />
+ public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings)
+ {
+ ArgumentNullException.ThrowIfNull(info);
+
+ var provider = GetProvider(info.Type);
+ await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false);
+
+ var config = _config.GetLiveTvConfiguration();
+
+ var list = config.ListingProviders.ToList();
+ int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
+ {
+ info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+ list.Add(info);
+ config.ListingProviders = list.ToArray();
+ }
+ else
+ {
+ config.ListingProviders[index] = info;
+ }
+
+ _config.SaveConfiguration("livetv", config);
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+
+ return info;
+ }
+
+ /// <inheritdoc />
+ public void DeleteListingsProvider(string? id)
+ {
+ var config = _config.GetLiveTvConfiguration();
+
+ config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
+
+ _config.SaveConfiguration("livetv", config);
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+ }
+
+ /// <inheritdoc />
+ public Task<List<NameIdPair>> GetLineups(string? providerType, string? providerId, string? country, string? location)
+ {
+ if (string.IsNullOrWhiteSpace(providerId))
+ {
+ return GetProvider(providerType).GetLineups(null, country, location);
+ }
+
+ var info = _config.GetLiveTvConfiguration().ListingProviders
+ .FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase))
+ ?? throw new ResourceNotFoundException();
+
+ return GetProvider(info.Type).GetLineups(info, country, location);
+ }
+
+ /// <inheritdoc />
+ public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(
+ ChannelInfo channel,
+ DateTime startDateUtc,
+ DateTime endDateUtc,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(channel);
+
+ foreach (var (provider, providerInfo) in GetListingProviders())
+ {
+ if (!IsListingProviderEnabledForTuner(providerInfo, channel.TunerHostId))
+ {
+ _logger.LogDebug(
+ "Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.",
+ channel.Number,
+ channel.Name,
+ provider.Name,
+ providerInfo.ListingsId ?? string.Empty);
+ continue;
+ }
+
+ _logger.LogDebug(
+ "Getting programs for channel {0}-{1} from {2}-{3}",
+ channel.Number,
+ channel.Name,
+ provider.Name,
+ providerInfo.ListingsId ?? string.Empty);
+
+ var epgChannels = await GetEpgChannels(provider, providerInfo, true, cancellationToken).ConfigureAwait(false);
+
+ var epgChannel = GetEpgChannelFromTunerChannel(providerInfo.ChannelMappings, channel, epgChannels);
+ if (epgChannel is null)
+ {
+ _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Name, providerInfo.ListingsId ?? string.Empty);
+ continue;
+ }
+
+ var programs = (await provider
+ .GetProgramsAsync(providerInfo, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken).ConfigureAwait(false))
+ .ToList();
+
+ // Replace the value that came from the provider with a normalized value
+ foreach (var program in programs)
+ {
+ program.ChannelId = channel.Id;
+ program.Id += "_" + channel.Id;
+ }
+
+ if (programs.Count > 0)
+ {
+ return programs;
+ }
+ }
+
+ return Enumerable.Empty<ProgramInfo>();
+ }
+
+ /// <inheritdoc />
+ public async Task AddProviderMetadata(IList<ChannelInfo> channels, bool enableCache, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(channels);
+
+ foreach (var (provider, providerInfo) in GetListingProviders())
+ {
+ var enabledChannels = channels
+ .Where(i => IsListingProviderEnabledForTuner(providerInfo, i.TunerHostId))
+ .ToList();
+
+ if (enabledChannels.Count == 0)
+ {
+ continue;
+ }
+
+ try
+ {
+ await AddMetadata(provider, providerInfo, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false);
+ }
+ catch (NotSupportedException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error adding metadata");
+ }
+ }
+ }
+
+ /// <inheritdoc />
+ public async Task<ChannelMappingOptionsDto> GetChannelMappingOptions(string? providerId)
+ {
+ var listingsProviderInfo = _config.GetLiveTvConfiguration().ListingProviders
+ .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ var provider = GetProvider(listingsProviderInfo.Type);
+
+ var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var providerChannels = await provider.GetChannels(listingsProviderInfo, default)
+ .ConfigureAwait(false);
+
+ var mappings = listingsProviderInfo.ChannelMappings;
+
+ return new ChannelMappingOptionsDto
+ {
+ TunerChannels = tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(),
+ ProviderChannels = providerChannels.Select(i => new NameIdPair
+ {
+ Name = i.Name,
+ Id = i.Id
+ }).ToList(),
+ Mappings = mappings,
+ ProviderName = provider.Name
+ };
+ }
+
+ /// <inheritdoc />
+ public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber)
+ {
+ var config = _config.GetLiveTvConfiguration();
+
+ var listingsProviderInfo = config.ListingProviders
+ .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings
+ .Where(pair => !string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray();
+
+ if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase))
+ {
+ var list = listingsProviderInfo.ChannelMappings.ToList();
+ list.Add(new NameValuePair
+ {
+ Name = tunerChannelNumber,
+ Value = providerChannelNumber
+ });
+ listingsProviderInfo.ChannelMappings = list.ToArray();
+ }
+
+ _config.SaveConfiguration("livetv", config);
+
+ var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var providerChannels = await GetProvider(listingsProviderInfo.Type).GetChannels(listingsProviderInfo, default)
+ .ConfigureAwait(false);
+
+ var tunerChannelMappings = tunerChannels
+ .Select(i => GetTunerChannelMapping(i, listingsProviderInfo.ChannelMappings, providerChannels)).ToList();
+
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+
+ return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private List<(IListingsProvider Provider, ListingsProviderInfo ProviderInfo)> GetListingProviders()
+ => _config.GetLiveTvConfiguration().ListingProviders
+ .Select(info => (
+ Provider: _listingsProviders.FirstOrDefault(l
+ => string.Equals(l.Type, info.Type, StringComparison.OrdinalIgnoreCase)),
+ ProviderInfo: info))
+ .Where(i => i.Provider is not null)
+ .ToList()!; // Already filtered out null
+
+ private async Task AddMetadata(
+ IListingsProvider provider,
+ ListingsProviderInfo info,
+ IEnumerable<ChannelInfo> tunerChannels,
+ bool enableCache,
+ CancellationToken cancellationToken)
+ {
+ var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false);
+
+ foreach (var tunerChannel in tunerChannels)
+ {
+ var epgChannel = GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels);
+ if (epgChannel is null)
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl))
+ {
+ tunerChannel.ImageUrl = epgChannel.ImageUrl;
+ }
+ }
+ }
+
+ private static bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId)
+ {
+ if (info.EnableAllTuners)
+ {
+ return true;
+ }
+
+ ArgumentException.ThrowIfNullOrWhiteSpace(tunerHostId);
+
+ return info.EnabledTuners.Contains(tunerHostId, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static string GetMappedChannel(string channelId, NameValuePair[] mappings)
+ {
+ foreach (NameValuePair mapping in mappings)
+ {
+ if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase))
+ {
+ return mapping.Value;
+ }
+ }
+
+ return channelId;
+ }
+
+ private async Task<EpgChannelData> GetEpgChannels(
+ IListingsProvider provider,
+ ListingsProviderInfo info,
+ bool enableCache,
+ CancellationToken cancellationToken)
+ {
+ if (enableCache && _epgChannels.TryGetValue(info.Id, out var result))
+ {
+ return result;
+ }
+
+ var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false);
+ foreach (var channel in channels)
+ {
+ _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id);
+ }
+
+ result = new EpgChannelData(channels);
+ _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result);
+
+ return result;
+ }
+
+ private static ChannelInfo? GetEpgChannelFromTunerChannel(
+ NameValuePair[] mappings,
+ ChannelInfo tunerChannel,
+ EpgChannelData epgChannelData)
+ {
+ if (!string.IsNullOrWhiteSpace(tunerChannel.Id))
+ {
+ var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings);
+ if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
+ {
+ mappedTunerChannelId = tunerChannel.Id;
+ }
+
+ var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
+ if (channel is not null)
+ {
+ return channel;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId))
+ {
+ var tunerChannelId = tunerChannel.TunerChannelId;
+ if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase))
+ {
+ tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I');
+ }
+
+ var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings);
+ if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
+ {
+ mappedTunerChannelId = tunerChannelId;
+ }
+
+ var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
+ if (channel is not null)
+ {
+ return channel;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
+ {
+ var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings);
+ if (string.IsNullOrWhiteSpace(tunerChannelNumber))
+ {
+ tunerChannelNumber = tunerChannel.Number;
+ }
+
+ var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber);
+ if (channel is not null)
+ {
+ return channel;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(tunerChannel.Name))
+ {
+ var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name);
+
+ var channel = epgChannelData.GetChannelByName(normalizedName);
+ if (channel is not null)
+ {
+ return channel;
+ }
+ }
+
+ return null;
+ }
+
+ private static TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, IList<ChannelInfo> providerChannels)
+ {
+ var result = new TunerChannelMapping
+ {
+ Name = tunerChannel.Name,
+ Id = tunerChannel.Id
+ };
+
+ if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
+ {
+ result.Name = tunerChannel.Number + " " + result.Name;
+ }
+
+ var providerChannel = GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(providerChannels));
+ if (providerChannel is not null)
+ {
+ result.ProviderChannelName = providerChannel.Name;
+ result.ProviderChannelId = providerChannel.Id;
+ }
+
+ return result;
+ }
+
+ private async Task<List<ChannelInfo>> GetChannelsForListingsProvider(ListingsProviderInfo info, CancellationToken cancellationToken)
+ {
+ var channels = new List<ChannelInfo>();
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
+ {
+ try
+ {
+ var tunerChannels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false);
+
+ channels.AddRange(tunerChannels.Where(channel => IsListingProviderEnabledForTuner(info, channel.TunerHostId)));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting channels");
+ }
+ }
+
+ return channels;
+ }
+
+ private IListingsProvider GetProvider(string? providerType)
+ => _listingsProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase))
+ ?? throw new ResourceNotFoundException($"Couldn't find provider of type {providerType}");
+}
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index ef5283b98..c19d8195c 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -6,14 +6,12 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
-using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
using Jellyfin.LiveTv.Configuration;
-using Jellyfin.LiveTv.Guide;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
@@ -27,7 +25,6 @@ using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.LiveTv
@@ -43,12 +40,11 @@ namespace Jellyfin.LiveTv
private readonly IDtoService _dtoService;
private readonly IUserDataManager _userDataManager;
private readonly ILibraryManager _libraryManager;
- private readonly ITaskManager _taskManager;
private readonly ILocalizationManager _localization;
private readonly IChannelManager _channelManager;
+ private readonly IRecordingsManager _recordingsManager;
private readonly LiveTvDtoService _tvDtoService;
private readonly ILiveTvService[] _services;
- private readonly IListingsProvider[] _listingProviders;
public LiveTvManager(
IServerConfigurationManager config,
@@ -57,27 +53,25 @@ namespace Jellyfin.LiveTv
IDtoService dtoService,
IUserManager userManager,
ILibraryManager libraryManager,
- ITaskManager taskManager,
ILocalizationManager localization,
IChannelManager channelManager,
+ IRecordingsManager recordingsManager,
LiveTvDtoService liveTvDtoService,
- IEnumerable<ILiveTvService> services,
- IEnumerable<IListingsProvider> listingProviders)
+ IEnumerable<ILiveTvService> services)
{
_config = config;
_logger = logger;
_userManager = userManager;
_libraryManager = libraryManager;
- _taskManager = taskManager;
_localization = localization;
_dtoService = dtoService;
_userDataManager = userDataManager;
_channelManager = channelManager;
_tvDtoService = liveTvDtoService;
+ _recordingsManager = recordingsManager;
_services = services.ToArray();
- _listingProviders = listingProviders.ToArray();
- var defaultService = _services.OfType<EmbyTV.EmbyTV>().First();
+ var defaultService = _services.OfType<DefaultLiveTvService>().First();
defaultService.TimerCreated += OnEmbyTvTimerCreated;
defaultService.TimerCancelled += OnEmbyTvTimerCancelled;
}
@@ -96,13 +90,6 @@ namespace Jellyfin.LiveTv
/// <value>The services.</value>
public IReadOnlyList<ILiveTvService> Services => _services;
- public IReadOnlyList<IListingsProvider> ListingProviders => _listingProviders;
-
- public string GetEmbyTvActiveRecordingPath(string id)
- {
- return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id);
- }
-
private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs<string> e)
{
var timerId = e.Argument;
@@ -164,73 +151,6 @@ namespace Jellyfin.LiveTv
return _libraryManager.GetItemsResult(internalQuery);
}
- public async Task<Tuple<MediaSourceInfo, ILiveStream>> GetChannelStream(string id, string mediaSourceId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
- {
- if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
- {
- mediaSourceId = null;
- }
-
- var channel = (LiveTvChannel)_libraryManager.GetItemById(id);
-
- bool isVideo = channel.ChannelType == ChannelType.TV;
- var service = GetService(channel);
- _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId);
-
- MediaSourceInfo info;
-#pragma warning disable CA1859 // TODO: Analyzer bug?
- ILiveStream liveStream;
-#pragma warning restore CA1859
- if (service is ISupportsDirectStreamProvider supportsManagedStream)
- {
- liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
- info = liveStream.MediaSource;
- }
- else
- {
- info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false);
- var openedId = info.Id;
- Func<Task> closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None);
-
- liveStream = new ExclusiveLiveStream(info, closeFn);
-
- var startTime = DateTime.UtcNow;
- await liveStream.Open(cancellationToken).ConfigureAwait(false);
- var endTime = DateTime.UtcNow;
- _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds);
- }
-
- info.RequiresClosing = true;
-
- var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_";
-
- info.LiveStreamId = idPrefix + info.Id;
-
- Normalize(info, service, isVideo);
-
- return new Tuple<MediaSourceInfo, ILiveStream>(info, liveStream);
- }
-
- public async Task<IEnumerable<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken)
- {
- var baseItem = (LiveTvChannel)item;
- var service = GetService(baseItem);
-
- var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false);
-
- if (sources.Count == 0)
- {
- throw new NotImplementedException();
- }
-
- foreach (var source in sources)
- {
- Normalize(source, service, baseItem.ChannelType == ChannelType.TV);
- }
-
- return sources;
- }
-
private ILiveTvService GetService(LiveTvChannel item)
{
var name = item.ServiceName;
@@ -252,127 +172,6 @@ namespace Jellyfin.LiveTv
"No service with the name '{0}' can be found.",
name));
- private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo)
- {
- // Not all of the plugins are setting this
- mediaSource.IsInfiniteStream = true;
-
- if (mediaSource.MediaStreams.Count == 0)
- {
- if (isVideo)
- {
- mediaSource.MediaStreams = new MediaStream[]
- {
- new MediaStream
- {
- Type = MediaStreamType.Video,
- // Set the index to -1 because we don't know the exact index of the video stream within the container
- Index = -1,
-
- // Set to true if unknown to enable deinterlacing
- IsInterlaced = true
- },
- new MediaStream
- {
- Type = MediaStreamType.Audio,
- // Set the index to -1 because we don't know the exact index of the audio stream within the container
- Index = -1
- }
- };
- }
- else
- {
- mediaSource.MediaStreams = new MediaStream[]
- {
- new MediaStream
- {
- Type = MediaStreamType.Audio,
- // Set the index to -1 because we don't know the exact index of the audio stream within the container
- Index = -1
- }
- };
- }
- }
-
- // Clean some bad data coming from providers
- foreach (var stream in mediaSource.MediaStreams)
- {
- if (stream.BitRate.HasValue && stream.BitRate <= 0)
- {
- stream.BitRate = null;
- }
-
- if (stream.Channels.HasValue && stream.Channels <= 0)
- {
- stream.Channels = null;
- }
-
- if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0)
- {
- stream.AverageFrameRate = null;
- }
-
- if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0)
- {
- stream.RealFrameRate = null;
- }
-
- if (stream.Width.HasValue && stream.Width <= 0)
- {
- stream.Width = null;
- }
-
- if (stream.Height.HasValue && stream.Height <= 0)
- {
- stream.Height = null;
- }
-
- if (stream.SampleRate.HasValue && stream.SampleRate <= 0)
- {
- stream.SampleRate = null;
- }
-
- if (stream.Level.HasValue && stream.Level <= 0)
- {
- stream.Level = null;
- }
- }
-
- var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList();
-
- // If there are duplicate stream indexes, set them all to unknown
- if (indexes.Count != mediaSource.MediaStreams.Count)
- {
- foreach (var stream in mediaSource.MediaStreams)
- {
- stream.Index = -1;
- }
- }
-
- // Set the total bitrate if not already supplied
- mediaSource.InferTotalBitrate();
-
- if (service is not EmbyTV.EmbyTV)
- {
- // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says
- // mediaSource.SupportsDirectPlay = false;
- // mediaSource.SupportsDirectStream = false;
- mediaSource.SupportsTranscoding = true;
- foreach (var stream in mediaSource.MediaStreams)
- {
- if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize))
- {
- stream.NalLengthSize = "0";
- }
-
- if (stream.Type == MediaStreamType.Video)
- {
- stream.IsInterlaced = true;
- }
- }
- }
- }
-
public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null)
{
var program = _libraryManager.GetItemById(id);
@@ -775,18 +574,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(DefaultLiveTvService.ServiceName, info.ChannelId));
dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId)
? null
@@ -1022,7 +816,7 @@ namespace Jellyfin.LiveTv
await service.CancelTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false);
- if (service is not EmbyTV.EmbyTV)
+ if (service is not DefaultLiveTvService)
{
TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(id)));
}
@@ -1331,7 +1125,7 @@ namespace Jellyfin.LiveTv
_logger.LogInformation("New recording scheduled");
- if (service is not EmbyTV.EmbyTV)
+ if (service is not DefaultLiveTvService)
{
TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>(
new TimerEventInfo(newTimerId)
@@ -1465,168 +1259,13 @@ namespace Jellyfin.LiveTv
return _libraryManager.GetNamedView(name, CollectionType.livetv, name);
}
- public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings)
- {
- // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider
- // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider
- info = JsonSerializer.Deserialize<ListingsProviderInfo>(JsonSerializer.SerializeToUtf8Bytes(info));
-
- var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
-
- if (provider is null)
- {
- throw new ResourceNotFoundException(
- string.Format(
- CultureInfo.InvariantCulture,
- "Couldn't find provider of type: '{0}'",
- info.Type));
- }
-
- await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false);
-
- var config = _config.GetLiveTvConfiguration();
-
- var list = config.ListingProviders.ToList();
- int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
-
- if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
- {
- info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
- list.Add(info);
- config.ListingProviders = list.ToArray();
- }
- else
- {
- config.ListingProviders[index] = info;
- }
-
- _config.SaveConfiguration("livetv", config);
-
- _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
-
- return info;
- }
-
- public void DeleteListingsProvider(string id)
- {
- var config = _config.GetLiveTvConfiguration();
-
- config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
-
- _config.SaveConfiguration("livetv", config);
- _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
- }
-
- public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber)
- {
- var config = _config.GetLiveTvConfiguration();
-
- var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase));
- listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray();
-
- if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase))
- {
- var list = listingsProviderInfo.ChannelMappings.ToList();
- list.Add(new NameValuePair
- {
- Name = tunerChannelNumber,
- Value = providerChannelNumber
- });
- listingsProviderInfo.ChannelMappings = list.ToArray();
- }
-
- _config.SaveConfiguration("livetv", config);
-
- var tunerChannels = await GetChannelsForListingsProvider(providerId, CancellationToken.None)
- .ConfigureAwait(false);
-
- var providerChannels = await GetChannelsFromListingsProviderData(providerId, CancellationToken.None)
- .ConfigureAwait(false);
-
- var mappings = listingsProviderInfo.ChannelMappings;
-
- var tunerChannelMappings =
- tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList();
-
- _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
-
- return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase));
- }
-
- public TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List<ChannelInfo> providerChannels)
- {
- var result = new TunerChannelMapping
- {
- Name = tunerChannel.Name,
- Id = tunerChannel.Id
- };
-
- if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
- {
- result.Name = tunerChannel.Number + " " + result.Name;
- }
-
- var providerChannel = EmbyTV.EmbyTV.Current.GetEpgChannelFromTunerChannel(mappings, tunerChannel, providerChannels);
-
- if (providerChannel is not null)
- {
- result.ProviderChannelName = providerChannel.Name;
- result.ProviderChannelId = providerChannel.Id;
- }
-
- return result;
- }
-
- public Task<List<NameIdPair>> GetLineups(string providerType, string providerId, string country, string location)
- {
- var config = _config.GetLiveTvConfiguration();
-
- if (string.IsNullOrWhiteSpace(providerId))
- {
- var provider = _listingProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase));
-
- if (provider is null)
- {
- throw new ResourceNotFoundException();
- }
-
- return provider.GetLineups(null, country, location);
- }
- else
- {
- var info = config.ListingProviders.FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase));
-
- var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
-
- if (provider is null)
- {
- throw new ResourceNotFoundException();
- }
-
- return provider.GetLineups(info, country, location);
- }
- }
-
- public Task<List<ChannelInfo>> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken)
- {
- var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
- return EmbyTV.EmbyTV.Current.GetChannelsForListingsProvider(info, cancellationToken);
- }
-
- public Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken)
- {
- var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
- var provider = _listingProviders.First(i => string.Equals(i.Type, info.Type, StringComparison.OrdinalIgnoreCase));
- return provider.GetChannels(info, cancellationToken);
- }
-
/// <inheritdoc />
public Task<BaseItem[]> GetRecordingFoldersAsync(User user)
=> GetRecordingFoldersAsync(user, false);
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))
diff --git a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
index ce9361089..40ac5ce0f 100644
--- a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
+++ b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
@@ -8,11 +8,15 @@ using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.LiveTv.IO;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
@@ -23,24 +27,34 @@ namespace Jellyfin.LiveTv
// Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
private const char StreamIdDelimiter = '_';
- private readonly ILiveTvManager _liveTvManager;
private readonly ILogger<LiveTvMediaSourceProvider> _logger;
- private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerApplicationHost _appHost;
+ private readonly IRecordingsManager _recordingsManager;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILiveTvService[] _services;
- public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost)
+ public LiveTvMediaSourceProvider(
+ ILogger<LiveTvMediaSourceProvider> logger,
+ IServerApplicationHost appHost,
+ IRecordingsManager recordingsManager,
+ IMediaSourceManager mediaSourceManager,
+ ILibraryManager libraryManager,
+ IEnumerable<ILiveTvService> services)
{
- _liveTvManager = liveTvManager;
_logger = logger;
- _mediaSourceManager = mediaSourceManager;
_appHost = appHost;
+ _recordingsManager = recordingsManager;
+ _mediaSourceManager = mediaSourceManager;
+ _libraryManager = libraryManager;
+ _services = services.ToArray();
}
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
{
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)
{
@@ -66,7 +80,7 @@ namespace Jellyfin.LiveTv
}
else
{
- sources = await _liveTvManager.GetChannelMediaSources(item, cancellationToken)
+ sources = await GetChannelMediaSources(item, cancellationToken)
.ConfigureAwait(false);
}
}
@@ -119,10 +133,200 @@ namespace Jellyfin.LiveTv
var keys = openToken.Split(StreamIdDelimiter, 3);
var mediaSourceId = keys.Length >= 3 ? keys[2] : null;
- var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+ var info = await GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
var liveStream = info.Item2;
return liveStream;
}
+
+ private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo)
+ {
+ // Not all of the plugins are setting this
+ mediaSource.IsInfiniteStream = true;
+
+ if (mediaSource.MediaStreams.Count == 0)
+ {
+ if (isVideo)
+ {
+ mediaSource.MediaStreams = new[]
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Video,
+ // Set the index to -1 because we don't know the exact index of the video stream within the container
+ Index = -1,
+ // Set to true if unknown to enable deinterlacing
+ IsInterlaced = true
+ },
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1
+ }
+ };
+ }
+ else
+ {
+ mediaSource.MediaStreams = new[]
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1
+ }
+ };
+ }
+ }
+
+ // Clean some bad data coming from providers
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (stream.BitRate is <= 0)
+ {
+ stream.BitRate = null;
+ }
+
+ if (stream.Channels is <= 0)
+ {
+ stream.Channels = null;
+ }
+
+ if (stream.AverageFrameRate is <= 0)
+ {
+ stream.AverageFrameRate = null;
+ }
+
+ if (stream.RealFrameRate is <= 0)
+ {
+ stream.RealFrameRate = null;
+ }
+
+ if (stream.Width is <= 0)
+ {
+ stream.Width = null;
+ }
+
+ if (stream.Height is <= 0)
+ {
+ stream.Height = null;
+ }
+
+ if (stream.SampleRate is <= 0)
+ {
+ stream.SampleRate = null;
+ }
+
+ if (stream.Level is <= 0)
+ {
+ stream.Level = null;
+ }
+ }
+
+ var indexCount = mediaSource.MediaStreams.Select(i => i.Index).Distinct().Count();
+
+ // If there are duplicate stream indexes, set them all to unknown
+ if (indexCount != mediaSource.MediaStreams.Count)
+ {
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ stream.Index = -1;
+ }
+ }
+
+ // Set the total bitrate if not already supplied
+ mediaSource.InferTotalBitrate();
+
+ if (service is not DefaultLiveTvService)
+ {
+ mediaSource.SupportsTranscoding = true;
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize))
+ {
+ stream.NalLengthSize = "0";
+ }
+
+ if (stream.Type == MediaStreamType.Video)
+ {
+ stream.IsInterlaced = true;
+ }
+ }
+ }
+ }
+
+ private async Task<Tuple<MediaSourceInfo, ILiveStream>> GetChannelStream(
+ string id,
+ string mediaSourceId,
+ List<ILiveStream> currentLiveStreams,
+ CancellationToken cancellationToken)
+ {
+ if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
+ {
+ mediaSourceId = null;
+ }
+
+ var channel = (LiveTvChannel)_libraryManager.GetItemById(id);
+
+ bool isVideo = channel.ChannelType == ChannelType.TV;
+ var service = GetService(channel.ServiceName);
+ _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId);
+
+ MediaSourceInfo info;
+#pragma warning disable CA1859 // TODO: Analyzer bug?
+ ILiveStream liveStream;
+#pragma warning restore CA1859
+ if (service is ISupportsDirectStreamProvider supportsManagedStream)
+ {
+ liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+ info = liveStream.MediaSource;
+ }
+ else
+ {
+ info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false);
+ var openedId = info.Id;
+ Func<Task> closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None);
+
+ liveStream = new ExclusiveLiveStream(info, closeFn);
+
+ var startTime = DateTime.UtcNow;
+ await liveStream.Open(cancellationToken).ConfigureAwait(false);
+ var endTime = DateTime.UtcNow;
+ _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds);
+ }
+
+ info.RequiresClosing = true;
+
+ var idPrefix = service.GetType().FullName!.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_";
+
+ info.LiveStreamId = idPrefix + info.Id;
+
+ Normalize(info, service, isVideo);
+
+ return new Tuple<MediaSourceInfo, ILiveStream>(info, liveStream);
+ }
+
+ private async Task<List<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken)
+ {
+ var baseItem = (LiveTvChannel)item;
+ var service = GetService(baseItem.ServiceName);
+
+ var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false);
+ if (sources.Count == 0)
+ {
+ throw new NotImplementedException();
+ }
+
+ foreach (var source in sources)
+ {
+ Normalize(source, service, baseItem.ChannelType == ChannelType.TV);
+ }
+
+ return sources;
+ }
+
+ private ILiveTvService GetService(string name)
+ => _services.First(service => string.Equals(service.Name, name, StringComparison.OrdinalIgnoreCase));
}
}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs
index 6bda231b2..2b7564045 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs
@@ -1,19 +1,12 @@
-#pragma warning disable CS1591
-
using System;
using System.Globalization;
using System.Text;
using MediaBrowser.Controller.LiveTv;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Recordings
{
internal static class RecordingHelper
{
- public static DateTime GetStartTime(TimerInfo timer)
- {
- return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds);
- }
-
public static string GetRecordingName(TimerInfo info)
{
var name = info.Name;
diff --git a/src/Jellyfin.LiveTv/RecordingNotifier.cs b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
index 226d525e7..e63afa626 100644
--- a/src/Jellyfin.LiveTv/RecordingNotifier.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
@@ -11,7 +11,7 @@ using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv
+namespace Jellyfin.LiveTv.Recordings
{
/// <summary>
/// <see cref="IHostedService"/> responsible for notifying users when a LiveTV recording is completed.
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs
new file mode 100644
index 000000000..f4daa0975
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs
@@ -0,0 +1,37 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.Timers;
+using MediaBrowser.Controller.LiveTv;
+using Microsoft.Extensions.Hosting;
+
+namespace Jellyfin.LiveTv.Recordings;
+
+/// <summary>
+/// <see cref="IHostedService"/> responsible for Live TV recordings.
+/// </summary>
+public sealed class RecordingsHost : IHostedService
+{
+ private readonly IRecordingsManager _recordingsManager;
+ private readonly TimerManager _timerManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RecordingsHost"/> class.
+ /// </summary>
+ /// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
+ /// <param name="timerManager">The <see cref="TimerManager"/>.</param>
+ public RecordingsHost(IRecordingsManager recordingsManager, TimerManager timerManager)
+ {
+ _recordingsManager = recordingsManager;
+ _timerManager = timerManager;
+ }
+
+ /// <inheritdoc />
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _timerManager.RestartTimers();
+ return _recordingsManager.CreateRecordingFolders();
+ }
+
+ /// <inheritdoc />
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
new file mode 100644
index 000000000..92605a1eb
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
@@ -0,0 +1,837 @@
+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.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");
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
new file mode 100644
index 000000000..b2b82332d
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
@@ -0,0 +1,501 @@
+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 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);
+ }
+}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
index 547ffeb66..18e4810a2 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs
+++ b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
@@ -9,7 +9,7 @@ using System.Text.Json;
using Jellyfin.Extensions.Json;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Timers
{
public class ItemDataProvider<T>
where T : class
diff --git a/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs
new file mode 100644
index 000000000..6e8444ba2
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs
@@ -0,0 +1,29 @@
+#pragma warning disable CS1591
+
+using System;
+using System.IO;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.Timers
+{
+ public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo>
+ {
+ public SeriesTimerManager(ILogger<SeriesTimerManager> logger, IConfigurationManager config)
+ : base(
+ logger,
+ Path.Combine(config.CommonApplicationPaths.DataPath, "livetv/seriestimers.json"),
+ (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ }
+
+ /// <inheritdoc />
+ public override void Add(SeriesTimerInfo item)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(item.Id);
+
+ base.Add(item);
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs
index 37b1fa14c..da5deea36 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs
+++ b/src/Jellyfin.LiveTv/Timers/TimerManager.cs
@@ -3,21 +3,27 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
+using System.IO;
using System.Linq;
using System.Threading;
using Jellyfin.Data.Events;
+using Jellyfin.LiveTv.Recordings;
+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,27 +86,16 @@ 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;
}
- var startDate = RecordingHelper.GetStartTime(item);
+ var startDate = item.StartDate.AddSeconds(-item.PrePaddingSeconds);
var now = DateTime.UtcNow;
if (startDate < now)
@@ -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));
}
}