You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
999 lines
38 KiB
999 lines
38 KiB
#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;
|
|
}
|
|
}
|
|
}
|