Merge pull request #11045 from barronpm/livetv-recordingsmanager
LiveTV Recordings Refactorpull/11048/head
commit
ca21a80c95
@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.LiveTv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service responsible for managing LiveTV recordings.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRecordingsManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the path for the provided timer id.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The timer id.</param>
|
||||||
|
/// <returns>The recording path, or <c>null</c> if none exists.</returns>
|
||||||
|
string? GetActiveRecordingPath(string id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the information for an active recording.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The recording path.</param>
|
||||||
|
/// <returns>The <see cref="ActiveRecordingInfo"/>, or <c>null</c> if none exists.</returns>
|
||||||
|
ActiveRecordingInfo? GetActiveRecordingInfo(string path);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the recording folders.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The <see cref="VirtualFolderInfo"/> for each recording folder.</returns>
|
||||||
|
IEnumerable<VirtualFolderInfo> GetRecordingFolders();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures that the recording folders all exist, and removes unused folders.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Task.</returns>
|
||||||
|
Task CreateRecordingFolders();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels the recording with the provided timer id, if one is active.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timerId">The timer id.</param>
|
||||||
|
/// <param name="timer">The timer.</param>
|
||||||
|
void CancelRecording(string timerId, TimerInfo? timer);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a stream.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="recordingInfo">The recording info.</param>
|
||||||
|
/// <param name="channel">The channel associated with the recording timer.</param>
|
||||||
|
/// <param name="recordingEndDate">The time to stop recording.</param>
|
||||||
|
/// <returns>Task representing the recording process.</returns>
|
||||||
|
Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate);
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,838 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using AsyncKeyedLock;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.LiveTv.Configuration;
|
||||||
|
using Jellyfin.LiveTv.EmbyTV;
|
||||||
|
using Jellyfin.LiveTv.IO;
|
||||||
|
using Jellyfin.LiveTv.Timers;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.LiveTv;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
using MediaBrowser.Model.LiveTv;
|
||||||
|
using MediaBrowser.Model.MediaInfo;
|
||||||
|
using MediaBrowser.Model.Providers;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.LiveTv.Recordings;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IRecordingsManager" />
|
||||||
|
public sealed class RecordingsManager : IRecordingsManager, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<RecordingsManager> _logger;
|
||||||
|
private readonly IServerConfigurationManager _config;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ILibraryMonitor _libraryMonitor;
|
||||||
|
private readonly IProviderManager _providerManager;
|
||||||
|
private readonly IMediaEncoder _mediaEncoder;
|
||||||
|
private readonly IMediaSourceManager _mediaSourceManager;
|
||||||
|
private readonly IStreamHelper _streamHelper;
|
||||||
|
private readonly TimerManager _timerManager;
|
||||||
|
private readonly SeriesTimerManager _seriesTimerManager;
|
||||||
|
private readonly RecordingsMetadataManager _recordingsMetadataManager;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RecordingsManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The <see cref="ILogger"/>.</param>
|
||||||
|
/// <param name="config">The <see cref="IServerConfigurationManager"/>.</param>
|
||||||
|
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
|
||||||
|
/// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
|
||||||
|
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
|
||||||
|
/// <param name="libraryMonitor">The <see cref="ILibraryMonitor"/>.</param>
|
||||||
|
/// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
|
||||||
|
/// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
|
||||||
|
/// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
|
||||||
|
/// <param name="streamHelper">The <see cref="IStreamHelper"/>.</param>
|
||||||
|
/// <param name="timerManager">The <see cref="TimerManager"/>.</param>
|
||||||
|
/// <param name="seriesTimerManager">The <see cref="SeriesTimerManager"/>.</param>
|
||||||
|
/// <param name="recordingsMetadataManager">The <see cref="RecordingsMetadataManager"/>.</param>
|
||||||
|
public RecordingsManager(
|
||||||
|
ILogger<RecordingsManager> logger,
|
||||||
|
IServerConfigurationManager config,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IFileSystem fileSystem,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
ILibraryMonitor libraryMonitor,
|
||||||
|
IProviderManager providerManager,
|
||||||
|
IMediaEncoder mediaEncoder,
|
||||||
|
IMediaSourceManager mediaSourceManager,
|
||||||
|
IStreamHelper streamHelper,
|
||||||
|
TimerManager timerManager,
|
||||||
|
SeriesTimerManager seriesTimerManager,
|
||||||
|
RecordingsMetadataManager recordingsMetadataManager)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_config = config;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_fileSystem = fileSystem;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_libraryMonitor = libraryMonitor;
|
||||||
|
_providerManager = providerManager;
|
||||||
|
_mediaEncoder = mediaEncoder;
|
||||||
|
_mediaSourceManager = mediaSourceManager;
|
||||||
|
_streamHelper = streamHelper;
|
||||||
|
_timerManager = timerManager;
|
||||||
|
_seriesTimerManager = seriesTimerManager;
|
||||||
|
_recordingsMetadataManager = recordingsMetadataManager;
|
||||||
|
|
||||||
|
_config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DefaultRecordingPath
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var path = _config.GetLiveTvConfiguration().RecordingPath;
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(path)
|
||||||
|
? Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv", "recordings")
|
||||||
|
: path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? GetActiveRecordingPath(string id)
|
||||||
|
=> _activeRecordings.GetValueOrDefault(id)?.Path;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ActiveRecordingInfo? GetActiveRecordingInfo(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (_, recordingInfo) in _activeRecordings)
|
||||||
|
{
|
||||||
|
if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal)
|
||||||
|
&& !recordingInfo.CancellationTokenSource.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return recordingInfo.Timer.Status == RecordingStatus.InProgress ? recordingInfo : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<VirtualFolderInfo> GetRecordingFolders()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(DefaultRecordingPath))
|
||||||
|
{
|
||||||
|
yield return new VirtualFolderInfo
|
||||||
|
{
|
||||||
|
Locations = [DefaultRecordingPath],
|
||||||
|
Name = "Recordings"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath;
|
||||||
|
if (!string.IsNullOrWhiteSpace(customPath)
|
||||||
|
&& !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& Directory.Exists(customPath))
|
||||||
|
{
|
||||||
|
yield return new VirtualFolderInfo
|
||||||
|
{
|
||||||
|
Locations = [customPath],
|
||||||
|
Name = "Recorded Movies",
|
||||||
|
CollectionType = CollectionTypeOptions.Movies
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath;
|
||||||
|
if (!string.IsNullOrWhiteSpace(customPath)
|
||||||
|
&& !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& Directory.Exists(customPath))
|
||||||
|
{
|
||||||
|
yield return new VirtualFolderInfo
|
||||||
|
{
|
||||||
|
Locations = [customPath],
|
||||||
|
Name = "Recorded Shows",
|
||||||
|
CollectionType = CollectionTypeOptions.TvShows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task CreateRecordingFolders()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var recordingFolders = GetRecordingFolders().ToArray();
|
||||||
|
var virtualFolders = _libraryManager.GetVirtualFolders();
|
||||||
|
|
||||||
|
var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList();
|
||||||
|
|
||||||
|
var pathsAdded = new List<string>();
|
||||||
|
|
||||||
|
foreach (var recordingFolder in recordingFolders)
|
||||||
|
{
|
||||||
|
var pathsToCreate = recordingFolder.Locations
|
||||||
|
.Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (pathsToCreate.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray();
|
||||||
|
var libraryOptions = new LibraryOptions
|
||||||
|
{
|
||||||
|
PathInfos = mediaPathInfos
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _libraryManager
|
||||||
|
.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error creating virtual folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
pathsAdded.AddRange(pathsToCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = _config.GetLiveTvConfiguration();
|
||||||
|
|
||||||
|
var pathsToRemove = config.MediaLocationsCreated
|
||||||
|
.Except(recordingFolders.SelectMany(i => i.Locations))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (pathsAdded.Count > 0 || pathsToRemove.Count > 0)
|
||||||
|
{
|
||||||
|
pathsAdded.InsertRange(0, config.MediaLocationsCreated);
|
||||||
|
config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
|
_config.SaveConfiguration("livetv", config);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in pathsToRemove)
|
||||||
|
{
|
||||||
|
await RemovePathFromLibraryAsync(path).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error creating recording folders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemovePathFromLibraryAsync(string path)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Removing path from library: {0}", path);
|
||||||
|
|
||||||
|
var requiresRefresh = false;
|
||||||
|
var virtualFolders = _libraryManager.GetVirtualFolders();
|
||||||
|
|
||||||
|
foreach (var virtualFolder in virtualFolders)
|
||||||
|
{
|
||||||
|
if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (virtualFolder.Locations.Length == 1)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error removing virtual folder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_libraryManager.RemoveMediaPath(virtualFolder.Name, path);
|
||||||
|
requiresRefresh = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error removing media path");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresRefresh)
|
||||||
|
{
|
||||||
|
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void CancelRecording(string timerId, TimerInfo? timer)
|
||||||
|
{
|
||||||
|
if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo))
|
||||||
|
{
|
||||||
|
activeRecordingInfo.Timer = timer;
|
||||||
|
activeRecordingInfo.CancellationTokenSource.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(recordingInfo);
|
||||||
|
ArgumentNullException.ThrowIfNull(channel);
|
||||||
|
|
||||||
|
var timer = recordingInfo.Timer;
|
||||||
|
var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
var recordingPath = GetRecordingPath(timer, remoteMetadata, out var seriesPath);
|
||||||
|
|
||||||
|
string? liveStreamId = null;
|
||||||
|
RecordingStatus recordingStatus;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var allMediaSources = await _mediaSourceManager
|
||||||
|
.GetPlaybackMediaSources(channel, null, true, false, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var mediaStreamInfo = allMediaSources[0];
|
||||||
|
IDirectStreamProvider? directStreamProvider = null;
|
||||||
|
if (mediaStreamInfo.RequiresOpening)
|
||||||
|
{
|
||||||
|
var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal(
|
||||||
|
new LiveStreamRequest
|
||||||
|
{
|
||||||
|
ItemId = channel.Id,
|
||||||
|
OpenToken = mediaStreamInfo.OpenToken
|
||||||
|
},
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
mediaStreamInfo = liveStreamResponse.Item1.MediaSource;
|
||||||
|
liveStreamId = mediaStreamInfo.LiveStreamId;
|
||||||
|
directStreamProvider = liveStreamResponse.Item2;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var recorder = GetRecorder(mediaStreamInfo);
|
||||||
|
|
||||||
|
recordingPath = recorder.GetOutputPath(mediaStreamInfo, recordingPath);
|
||||||
|
recordingPath = EnsureFileUnique(recordingPath, timer.Id);
|
||||||
|
|
||||||
|
_libraryMonitor.ReportFileSystemChangeBeginning(recordingPath);
|
||||||
|
|
||||||
|
var duration = recordingEndDate - DateTime.UtcNow;
|
||||||
|
|
||||||
|
_logger.LogInformation("Beginning recording. Will record for {Duration} minutes.", duration.TotalMinutes);
|
||||||
|
_logger.LogInformation("Writing file to: {Path}", recordingPath);
|
||||||
|
|
||||||
|
async void OnStarted()
|
||||||
|
{
|
||||||
|
recordingInfo.Path = recordingPath;
|
||||||
|
_activeRecordings.TryAdd(timer.Id, recordingInfo);
|
||||||
|
|
||||||
|
timer.Status = RecordingStatus.InProgress;
|
||||||
|
_timerManager.AddOrUpdate(timer, false);
|
||||||
|
|
||||||
|
await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordingPath, seriesPath).ConfigureAwait(false);
|
||||||
|
await CreateRecordingFolders().ConfigureAwait(false);
|
||||||
|
|
||||||
|
TriggerRefresh(recordingPath);
|
||||||
|
await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await recorder.Record(
|
||||||
|
directStreamProvider,
|
||||||
|
mediaStreamInfo,
|
||||||
|
recordingPath,
|
||||||
|
duration,
|
||||||
|
OnStarted,
|
||||||
|
recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
recordingStatus = RecordingStatus.Completed;
|
||||||
|
_logger.LogInformation("Recording completed: {RecordPath}", recordingPath);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Recording stopped: {RecordPath}", recordingPath);
|
||||||
|
recordingStatus = RecordingStatus.Completed;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error recording to {RecordPath}", recordingPath);
|
||||||
|
recordingStatus = RecordingStatus.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(liveStreamId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error closing live stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteFileIfEmpty(recordingPath);
|
||||||
|
TriggerRefresh(recordingPath);
|
||||||
|
_libraryMonitor.ReportFileSystemChangeComplete(recordingPath, false);
|
||||||
|
_activeRecordings.TryRemove(timer.Id, out _);
|
||||||
|
|
||||||
|
if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10)
|
||||||
|
{
|
||||||
|
const int RetryIntervalSeconds = 60;
|
||||||
|
_logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds);
|
||||||
|
|
||||||
|
timer.Status = RecordingStatus.New;
|
||||||
|
timer.PrePaddingSeconds = 0;
|
||||||
|
timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds);
|
||||||
|
timer.RetryCount++;
|
||||||
|
_timerManager.AddOrUpdate(timer);
|
||||||
|
}
|
||||||
|
else if (File.Exists(recordingPath))
|
||||||
|
{
|
||||||
|
timer.RecordingPath = recordingPath;
|
||||||
|
timer.Status = RecordingStatus.Completed;
|
||||||
|
_timerManager.AddOrUpdate(timer, false);
|
||||||
|
await PostProcessRecording(recordingPath).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_timerManager.Delete(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordingDeleteSemaphore.Dispose();
|
||||||
|
|
||||||
|
foreach (var pair in _activeRecordings.ToList())
|
||||||
|
{
|
||||||
|
pair.Value.CancellationTokenSource.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e)
|
||||||
|
{
|
||||||
|
if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await CreateRecordingFolders().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RemoteSearchResult?> FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!timer.IsSeries || timer.SeriesProviderIds.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = new RemoteSearchQuery<SeriesInfo>
|
||||||
|
{
|
||||||
|
SearchInfo = new SeriesInfo
|
||||||
|
{
|
||||||
|
ProviderIds = timer.SeriesProviderIds,
|
||||||
|
Name = timer.Name,
|
||||||
|
MetadataCountryCode = _config.Configuration.MetadataCountryCode,
|
||||||
|
MetadataLanguage = _config.Configuration.PreferredMetadataLanguage
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return results.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetRecordingPath(TimerInfo timer, RemoteSearchResult? metadata, out string? seriesPath)
|
||||||
|
{
|
||||||
|
var recordingPath = DefaultRecordingPath;
|
||||||
|
var config = _config.GetLiveTvConfiguration();
|
||||||
|
seriesPath = null;
|
||||||
|
|
||||||
|
if (timer.IsProgramSeries)
|
||||||
|
{
|
||||||
|
var customRecordingPath = config.SeriesRecordingPath;
|
||||||
|
var allowSubfolder = true;
|
||||||
|
if (!string.IsNullOrWhiteSpace(customRecordingPath))
|
||||||
|
{
|
||||||
|
allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
|
||||||
|
recordingPath = customRecordingPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowSubfolder && config.EnableRecordingSubfolders)
|
||||||
|
{
|
||||||
|
recordingPath = Path.Combine(recordingPath, "Series");
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim trailing period from the folder name
|
||||||
|
var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim();
|
||||||
|
|
||||||
|
if (metadata is not null && metadata.ProductionYear.HasValue)
|
||||||
|
{
|
||||||
|
folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't use the year here in the folder name because it is the year of the episode, not the series.
|
||||||
|
recordingPath = Path.Combine(recordingPath, folderName);
|
||||||
|
|
||||||
|
seriesPath = recordingPath;
|
||||||
|
|
||||||
|
if (timer.SeasonNumber.HasValue)
|
||||||
|
{
|
||||||
|
folderName = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"Season {0}",
|
||||||
|
timer.SeasonNumber.Value);
|
||||||
|
recordingPath = Path.Combine(recordingPath, folderName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (timer.IsMovie)
|
||||||
|
{
|
||||||
|
var customRecordingPath = config.MovieRecordingPath;
|
||||||
|
var allowSubfolder = true;
|
||||||
|
if (!string.IsNullOrWhiteSpace(customRecordingPath))
|
||||||
|
{
|
||||||
|
allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
|
||||||
|
recordingPath = customRecordingPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowSubfolder && config.EnableRecordingSubfolders)
|
||||||
|
{
|
||||||
|
recordingPath = Path.Combine(recordingPath, "Movies");
|
||||||
|
}
|
||||||
|
|
||||||
|
var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
|
||||||
|
if (timer.ProductionYear.HasValue)
|
||||||
|
{
|
||||||
|
folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim trailing period from the folder name
|
||||||
|
folderName = folderName.TrimEnd('.').Trim();
|
||||||
|
|
||||||
|
recordingPath = Path.Combine(recordingPath, folderName);
|
||||||
|
}
|
||||||
|
else if (timer.IsKids)
|
||||||
|
{
|
||||||
|
if (config.EnableRecordingSubfolders)
|
||||||
|
{
|
||||||
|
recordingPath = Path.Combine(recordingPath, "Kids");
|
||||||
|
}
|
||||||
|
|
||||||
|
var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
|
||||||
|
if (timer.ProductionYear.HasValue)
|
||||||
|
{
|
||||||
|
folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim trailing period from the folder name
|
||||||
|
folderName = folderName.TrimEnd('.').Trim();
|
||||||
|
|
||||||
|
recordingPath = Path.Combine(recordingPath, folderName);
|
||||||
|
}
|
||||||
|
else if (timer.IsSports)
|
||||||
|
{
|
||||||
|
if (config.EnableRecordingSubfolders)
|
||||||
|
{
|
||||||
|
recordingPath = Path.Combine(recordingPath, "Sports");
|
||||||
|
}
|
||||||
|
|
||||||
|
recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (config.EnableRecordingSubfolders)
|
||||||
|
{
|
||||||
|
recordingPath = Path.Combine(recordingPath, "Other");
|
||||||
|
}
|
||||||
|
|
||||||
|
recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts";
|
||||||
|
|
||||||
|
return Path.Combine(recordingPath, recordingFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteFileIfEmpty(string path)
|
||||||
|
{
|
||||||
|
var file = _fileSystem.GetFileInfo(path);
|
||||||
|
|
||||||
|
if (file.Exists && file.Length == 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_fileSystem.DeleteFile(path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TriggerRefresh(string path)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Triggering refresh on {Path}", path);
|
||||||
|
|
||||||
|
var item = GetAffectedBaseItem(Path.GetDirectoryName(path));
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Refreshing recording parent {Path}", item.Path);
|
||||||
|
_providerManager.QueueRefresh(
|
||||||
|
item.Id,
|
||||||
|
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||||
|
{
|
||||||
|
RefreshPaths =
|
||||||
|
[
|
||||||
|
path,
|
||||||
|
Path.GetDirectoryName(path),
|
||||||
|
Path.GetDirectoryName(Path.GetDirectoryName(path))
|
||||||
|
]
|
||||||
|
},
|
||||||
|
RefreshPriority.High);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BaseItem? GetAffectedBaseItem(string? path)
|
||||||
|
{
|
||||||
|
BaseItem? item = null;
|
||||||
|
var parentPath = Path.GetDirectoryName(path);
|
||||||
|
while (item is null && !string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
item = _libraryManager.FindByPath(path, null);
|
||||||
|
path = Path.GetDirectoryName(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item is not null
|
||||||
|
&& item.GetType() == typeof(Folder)
|
||||||
|
&& string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var parentItem = item.GetParent();
|
||||||
|
if (parentItem is not null && parentItem is not AggregateFolder)
|
||||||
|
{
|
||||||
|
item = parentItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnforceKeepUpTo(TimerInfo timer, string? seriesPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)
|
||||||
|
|| string.IsNullOrWhiteSpace(seriesPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seriesTimerId = timer.SeriesTimerId;
|
||||||
|
var seriesTimer = _seriesTimerManager.GetAll()
|
||||||
|
.FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (seriesTimer is null || seriesTimer.KeepUpTo <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var timersToDelete = _timerManager.GetAll()
|
||||||
|
.Where(timerInfo => timerInfo.Status == RecordingStatus.Completed
|
||||||
|
&& !string.IsNullOrWhiteSpace(timerInfo.RecordingPath)
|
||||||
|
&& string.Equals(timerInfo.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& File.Exists(timerInfo.RecordingPath))
|
||||||
|
.OrderByDescending(i => i.EndDate)
|
||||||
|
.Skip(seriesTimer.KeepUpTo - 1)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
DeleteLibraryItemsForTimers(timersToDelete);
|
||||||
|
|
||||||
|
if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var episodesToDelete = librarySeries.GetItemList(
|
||||||
|
new InternalItemsQuery
|
||||||
|
{
|
||||||
|
OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending)],
|
||||||
|
IsVirtualItem = false,
|
||||||
|
IsFolder = false,
|
||||||
|
Recursive = true,
|
||||||
|
DtoOptions = new DtoOptions(true)
|
||||||
|
})
|
||||||
|
.Where(i => i.IsFileProtocol && File.Exists(i.Path))
|
||||||
|
.Skip(seriesTimer.KeepUpTo - 1);
|
||||||
|
|
||||||
|
foreach (var item in episodesToDelete)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_libraryManager.DeleteItem(
|
||||||
|
item,
|
||||||
|
new DeleteOptions
|
||||||
|
{
|
||||||
|
DeleteFileLocation = true
|
||||||
|
},
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting item");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteLibraryItemsForTimers(List<TimerInfo> timers)
|
||||||
|
{
|
||||||
|
foreach (var timer in timers)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DeleteLibraryItemForTimer(timer);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting recording");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteLibraryItemForTimer(TimerInfo timer)
|
||||||
|
{
|
||||||
|
var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false);
|
||||||
|
if (libraryItem is not null)
|
||||||
|
{
|
||||||
|
_libraryManager.DeleteItem(
|
||||||
|
libraryItem,
|
||||||
|
new DeleteOptions
|
||||||
|
{
|
||||||
|
DeleteFileLocation = true
|
||||||
|
},
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
else if (File.Exists(timer.RecordingPath))
|
||||||
|
{
|
||||||
|
_fileSystem.DeleteFile(timer.RecordingPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
_timerManager.Delete(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string EnsureFileUnique(string path, string timerId)
|
||||||
|
{
|
||||||
|
var parent = Path.GetDirectoryName(path)!;
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var extension = Path.GetExtension(path);
|
||||||
|
|
||||||
|
var index = 1;
|
||||||
|
while (File.Exists(path) || _activeRecordings.Any(i
|
||||||
|
=> string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
name += " - " + index.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
path = Path.ChangeExtension(Path.Combine(parent, name), extension);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IRecorder GetRecorder(MediaSourceInfo mediaSource)
|
||||||
|
{
|
||||||
|
if (mediaSource.RequiresLooping
|
||||||
|
|| !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http))
|
||||||
|
{
|
||||||
|
return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DirectRecorder(_logger, _httpClientFactory, _streamHelper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PostProcessRecording(string path)
|
||||||
|
{
|
||||||
|
var options = _config.GetLiveTvConfiguration();
|
||||||
|
if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
Arguments = options.RecordingPostProcessorArguments
|
||||||
|
.Replace("{path}", path, StringComparison.OrdinalIgnoreCase),
|
||||||
|
CreateNoWindow = true,
|
||||||
|
ErrorDialog = false,
|
||||||
|
FileName = options.RecordingPostProcessor,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden,
|
||||||
|
UseShellExecute = false
|
||||||
|
};
|
||||||
|
process.EnableRaisingEvents = true;
|
||||||
|
|
||||||
|
_logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error running recording post processor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,502 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using Jellyfin.LiveTv.Configuration;
|
||||||
|
using Jellyfin.LiveTv.EmbyTV;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.LiveTv;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.LiveTv.Recordings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A service responsible for saving recording metadata.
|
||||||
|
/// </summary>
|
||||||
|
public class RecordingsMetadataManager
|
||||||
|
{
|
||||||
|
private const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss";
|
||||||
|
|
||||||
|
private readonly ILogger<RecordingsMetadataManager> _logger;
|
||||||
|
private readonly IConfigurationManager _config;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RecordingsMetadataManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The <see cref="ILogger"/>.</param>
|
||||||
|
/// <param name="config">The <see cref="IConfigurationManager"/>.</param>
|
||||||
|
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
|
||||||
|
public RecordingsMetadataManager(
|
||||||
|
ILogger<RecordingsMetadataManager> logger,
|
||||||
|
IConfigurationManager config,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_config = config;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the metadata for a provided recording.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timer">The recording timer.</param>
|
||||||
|
/// <param name="recordingPath">The recording path.</param>
|
||||||
|
/// <param name="seriesPath">The series path.</param>
|
||||||
|
/// <returns>A task representing the metadata saving.</returns>
|
||||||
|
public async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string? seriesPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.LiveTvProgram],
|
||||||
|
Limit = 1,
|
||||||
|
ExternalId = timer.ProgramId,
|
||||||
|
DtoOptions = new DtoOptions(true)
|
||||||
|
}).FirstOrDefault() as LiveTvProgram;
|
||||||
|
|
||||||
|
// dummy this up
|
||||||
|
program ??= new LiveTvProgram
|
||||||
|
{
|
||||||
|
Name = timer.Name,
|
||||||
|
Overview = timer.Overview,
|
||||||
|
Genres = timer.Genres,
|
||||||
|
CommunityRating = timer.CommunityRating,
|
||||||
|
OfficialRating = timer.OfficialRating,
|
||||||
|
ProductionYear = timer.ProductionYear,
|
||||||
|
PremiereDate = timer.OriginalAirDate,
|
||||||
|
IndexNumber = timer.EpisodeNumber,
|
||||||
|
ParentIndexNumber = timer.SeasonNumber
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timer.IsSports)
|
||||||
|
{
|
||||||
|
program.AddGenre("Sports");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timer.IsKids)
|
||||||
|
{
|
||||||
|
program.AddGenre("Kids");
|
||||||
|
program.AddGenre("Children");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timer.IsNews)
|
||||||
|
{
|
||||||
|
program.AddGenre("News");
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = _config.GetLiveTvConfiguration();
|
||||||
|
|
||||||
|
if (config.SaveRecordingNFO)
|
||||||
|
{
|
||||||
|
if (timer.IsProgramSeries)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(seriesPath);
|
||||||
|
|
||||||
|
await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
|
||||||
|
await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
|
||||||
|
{
|
||||||
|
await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.SaveRecordingImages)
|
||||||
|
{
|
||||||
|
await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error saving nfo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath)
|
||||||
|
{
|
||||||
|
var nfoPath = Path.Combine(seriesPath, "tvshow.nfo");
|
||||||
|
|
||||||
|
if (File.Exists(nfoPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||||
|
await using (stream.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var settings = new XmlWriterSettings
|
||||||
|
{
|
||||||
|
Indent = true,
|
||||||
|
Encoding = Encoding.UTF8,
|
||||||
|
Async = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var writer = XmlWriter.Create(stream, settings);
|
||||||
|
await using (writer.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
|
||||||
|
await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false);
|
||||||
|
if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "imdb_id", null, id).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "tmdbid", null, id).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "zap2itid", null, id).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(timer.Name))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "title", null, timer.Name).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(timer.OfficialRating))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "mpaa", null, timer.OfficialRating).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var genre in timer.Genres)
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.WriteEndElementAsync().ConfigureAwait(false);
|
||||||
|
await writer.WriteEndDocumentAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveVideoNfoAsync(TimerInfo timer, string recordingPath, BaseItem item, bool lockData)
|
||||||
|
{
|
||||||
|
var nfoPath = Path.ChangeExtension(recordingPath, ".nfo");
|
||||||
|
|
||||||
|
if (File.Exists(nfoPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||||
|
await using (stream.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var settings = new XmlWriterSettings
|
||||||
|
{
|
||||||
|
Indent = true,
|
||||||
|
Encoding = Encoding.UTF8,
|
||||||
|
Async = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = _config.GetNfoConfiguration();
|
||||||
|
|
||||||
|
var isSeriesEpisode = timer.IsProgramSeries;
|
||||||
|
|
||||||
|
var writer = XmlWriter.Create(stream, settings);
|
||||||
|
await using (writer.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (isSeriesEpisode)
|
||||||
|
{
|
||||||
|
await writer.WriteStartElementAsync(null, "episodedetails", null).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "title", null, timer.EpisodeTitle).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null);
|
||||||
|
|
||||||
|
if (premiereDate.HasValue)
|
||||||
|
{
|
||||||
|
var formatString = options.ReleaseDateFormat;
|
||||||
|
|
||||||
|
await writer.WriteElementStringAsync(
|
||||||
|
null,
|
||||||
|
"aired",
|
||||||
|
null,
|
||||||
|
premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.IndexNumber.HasValue)
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "episode", null, item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ParentIndexNumber.HasValue)
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "season", null, item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.Name))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.OriginalTitle))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.PremiereDate.HasValue)
|
||||||
|
{
|
||||||
|
var formatString = options.ReleaseDateFormat;
|
||||||
|
|
||||||
|
await writer.WriteElementStringAsync(
|
||||||
|
null,
|
||||||
|
"premiered",
|
||||||
|
null,
|
||||||
|
item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||||
|
await writer.WriteElementStringAsync(
|
||||||
|
null,
|
||||||
|
"releasedate",
|
||||||
|
null,
|
||||||
|
item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.WriteElementStringAsync(
|
||||||
|
null,
|
||||||
|
"dateadded",
|
||||||
|
null,
|
||||||
|
DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (item.ProductionYear.HasValue)
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(item.OfficialRating))
|
||||||
|
{
|
||||||
|
await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var overview = (item.Overview ?? string.Empty)
|
||||||
|
.StripHtml()
|
||||||
|
.Replace(""", "'", 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue