Add IGuideManager service

This commit is contained in:
Patrick Barron 2024-01-17 09:50:35 -05:00
parent 484ccf7f28
commit 59c2ae944d
7 changed files with 755 additions and 682 deletions

View File

@ -42,6 +42,7 @@ namespace Jellyfin.Api.Controllers;
public class LiveTvController : BaseJellyfinApiController public class LiveTvController : BaseJellyfinApiController
{ {
private readonly ILiveTvManager _liveTvManager; private readonly ILiveTvManager _liveTvManager;
private readonly IGuideManager _guideManager;
private readonly ITunerHostManager _tunerHostManager; private readonly ITunerHostManager _tunerHostManager;
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
@ -55,6 +56,7 @@ public class LiveTvController : BaseJellyfinApiController
/// Initializes a new instance of the <see cref="LiveTvController"/> class. /// Initializes a new instance of the <see cref="LiveTvController"/> class.
/// </summary> /// </summary>
/// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param> /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param>
/// <param name="guideManager">Instance of the <see cref="IGuideManager"/> interface.</param>
/// <param name="tunerHostManager">Instance of the <see cref="ITunerHostManager"/> interface.</param> /// <param name="tunerHostManager">Instance of the <see cref="ITunerHostManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
@ -65,6 +67,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param> /// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
public LiveTvController( public LiveTvController(
ILiveTvManager liveTvManager, ILiveTvManager liveTvManager,
IGuideManager guideManager,
ITunerHostManager tunerHostManager, ITunerHostManager tunerHostManager,
IUserManager userManager, IUserManager userManager,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
@ -75,6 +78,7 @@ public class LiveTvController : BaseJellyfinApiController
ITranscodeManager transcodeManager) ITranscodeManager transcodeManager)
{ {
_liveTvManager = liveTvManager; _liveTvManager = liveTvManager;
_guideManager = guideManager;
_tunerHostManager = tunerHostManager; _tunerHostManager = tunerHostManager;
_userManager = userManager; _userManager = userManager;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
@ -940,9 +944,7 @@ public class LiveTvController : BaseJellyfinApiController
[Authorize(Policy = Policies.LiveTvAccess)] [Authorize(Policy = Policies.LiveTvAccess)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<GuideInfo> GetGuideInfo() public ActionResult<GuideInfo> GetGuideInfo()
{ => _guideManager.GetGuideInfo();
return _liveTvManager.GetGuideInfo();
}
/// <summary> /// <summary>
/// Adds a tuner host. /// Adds a tuner host.

View File

@ -0,0 +1,26 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.LiveTv;
namespace MediaBrowser.Controller.LiveTv;
/// <summary>
/// Service responsible for managing the Live TV guide.
/// </summary>
public interface IGuideManager
{
/// <summary>
/// Gets the guide information.
/// </summary>
/// <returns>The <see cref="GuideInfo"/>.</returns>
GuideInfo GetGuideInfo();
/// <summary>
/// Refresh the guide.
/// </summary>
/// <param name="progress">The <see cref="IProgress{T}"/> to use.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to use.</param>
/// <returns>Task representing the refresh operation.</returns>
Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken);
}

View File

@ -174,12 +174,6 @@ namespace MediaBrowser.Controller.LiveTv
/// <returns>Task.</returns> /// <returns>Task.</returns>
Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken); Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken);
/// <summary>
/// Gets the guide information.
/// </summary>
/// <returns>GuideInfo.</returns>
GuideInfo GetGuideInfo();
/// <summary> /// <summary>
/// Gets the recommended programs. /// Gets the recommended programs.
/// </summary> /// </summary>

View File

@ -1,4 +1,5 @@
using Jellyfin.LiveTv.Channels; using Jellyfin.LiveTv.Channels;
using Jellyfin.LiveTv.Guide;
using Jellyfin.LiveTv.TunerHosts; using Jellyfin.LiveTv.TunerHosts;
using Jellyfin.LiveTv.TunerHosts.HdHomerun; using Jellyfin.LiveTv.TunerHosts.HdHomerun;
using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Channels;
@ -24,6 +25,7 @@ public static class LiveTvServiceCollectionExtensions
services.AddSingleton<IChannelManager, ChannelManager>(); services.AddSingleton<IChannelManager, ChannelManager>();
services.AddSingleton<IStreamHelper, StreamHelper>(); services.AddSingleton<IStreamHelper, StreamHelper>();
services.AddSingleton<ITunerHostManager, TunerHostManager>(); services.AddSingleton<ITunerHostManager, TunerHostManager>();
services.AddSingleton<IGuideManager, GuideManager>();
services.AddSingleton<ITunerHost, HdHomerunHost>(); services.AddSingleton<ITunerHost, HdHomerunHost>();
services.AddSingleton<ITunerHost, M3UTunerHost>(); services.AddSingleton<ITunerHost, M3UTunerHost>();

View File

@ -0,0 +1,713 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.LiveTv.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Progress;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
namespace Jellyfin.LiveTv.Guide;
/// <inheritdoc />
public class GuideManager : IGuideManager
{
private const int MaxGuideDays = 14;
private const string EtagKey = "ProgramEtag";
private const string ExternalServiceTag = "ExternalServiceId";
private readonly ILogger<GuideManager> _logger;
private readonly IConfigurationManager _config;
private readonly IFileSystem _fileSystem;
private readonly IItemRepository _itemRepo;
private readonly ILibraryManager _libraryManager;
private readonly ILiveTvManager _liveTvManager;
private readonly ITunerHostManager _tunerHostManager;
private readonly LiveTvDtoService _tvDtoService;
/// <summary>
/// Initializes a new instance of the <see cref="GuideManager"/> class.
/// </summary>
/// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
/// <param name="config">The <see cref="IConfigurationManager"/>.</param>
/// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
/// <param name="itemRepo">The <see cref="IItemRepository"/>.</param>
/// <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="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
public GuideManager(
ILogger<GuideManager> logger,
IConfigurationManager config,
IFileSystem fileSystem,
IItemRepository itemRepo,
ILibraryManager libraryManager,
ILiveTvManager liveTvManager,
ITunerHostManager tunerHostManager,
LiveTvDtoService tvDtoService)
{
_logger = logger;
_config = config;
_fileSystem = fileSystem;
_itemRepo = itemRepo;
_libraryManager = libraryManager;
_liveTvManager = liveTvManager;
_tunerHostManager = tunerHostManager;
_tvDtoService = tvDtoService;
}
/// <inheritdoc />
public GuideInfo GetGuideInfo()
{
var startDate = DateTime.UtcNow;
var endDate = startDate.AddDays(GetGuideDays());
return new GuideInfo
{
StartDate = startDate,
EndDate = endDate
};
}
/// <inheritdoc />
public async Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(progress);
await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false);
await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
var numComplete = 0;
double progressPerService = _liveTvManager.Services.Count == 0
? 0
: 1.0 / _liveTvManager.Services.Count;
var newChannelIdList = new List<Guid>();
var newProgramIdList = new List<Guid>();
var cleanDatabase = true;
foreach (var service in _liveTvManager.Services)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Refreshing guide from {Name}", service.Name);
try
{
var innerProgress = new ActionableProgress<double>();
innerProgress.RegisterAction(p => progress.Report(p * progressPerService));
var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false);
newChannelIdList.AddRange(idList.Item1);
newProgramIdList.AddRange(idList.Item2);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
cleanDatabase = false;
_logger.LogError(ex, "Error refreshing channels for service");
}
numComplete++;
double percent = numComplete;
percent /= _liveTvManager.Services.Count;
progress.Report(100 * percent);
}
if (cleanDatabase)
{
CleanDatabase(newChannelIdList.ToArray(), [BaseItemKind.LiveTvChannel], progress, cancellationToken);
CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken);
}
var coreService = _liveTvManager.Services.OfType<EmbyTV.EmbyTV>().FirstOrDefault();
if (coreService is not null)
{
await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false);
}
// Load these now which will prefetch metadata
var dtoOptions = new DtoOptions();
var fields = dtoOptions.Fields.ToList();
dtoOptions.Fields = fields.ToArray();
progress.Report(100);
}
private double GetGuideDays()
{
var config = _config.GetLiveTvConfiguration();
return config.GuideDays.HasValue
? Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays))
: 7;
}
private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress<double> progress, CancellationToken cancellationToken)
{
progress.Report(10);
var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
.Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
.ToList();
var list = new List<LiveTvChannel>();
var numComplete = 0;
var parentFolder = _liveTvManager.GetInternalLiveTvFolder(cancellationToken);
foreach (var channelInfo in allChannelsList)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false);
list.Add(item);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
}
numComplete++;
double percent = numComplete;
percent /= allChannelsList.Count;
progress.Report((5 * percent) + 10);
}
progress.Report(15);
numComplete = 0;
var programs = new List<Guid>();
var channels = new List<Guid>();
var guideDays = GetGuideDays();
_logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays);
foreach (var currentChannel in list)
{
cancellationToken.ThrowIfCancellationRequested();
channels.Add(currentChannel.Id);
try
{
var start = DateTime.UtcNow.AddHours(-1);
var end = start.AddDays(guideDays);
var isMovie = false;
var isSports = false;
var isNews = false;
var isKids = false;
var isSeries = false;
var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList();
var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.LiveTvProgram],
ChannelIds = new[] { currentChannel.Id },
DtoOptions = new DtoOptions(true)
}).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
var newPrograms = new List<LiveTvProgram>();
var updatedPrograms = new List<BaseItem>();
foreach (var program in channelPrograms)
{
var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel);
if (isNew)
{
newPrograms.Add(programItem);
}
else if (isUpdated)
{
updatedPrograms.Add(programItem);
}
programs.Add(programItem.Id);
isMovie |= program.IsMovie;
isSeries |= program.IsSeries;
isSports |= program.IsSports;
isNews |= program.IsNews;
isKids |= program.IsKids;
}
_logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count);
if (newPrograms.Count > 0)
{
_libraryManager.CreateItems(newPrograms, null, cancellationToken);
}
if (updatedPrograms.Count > 0)
{
await _libraryManager.UpdateItemsAsync(
updatedPrograms,
currentChannel,
ItemUpdateType.MetadataImport,
cancellationToken).ConfigureAwait(false);
}
currentChannel.IsMovie = isMovie;
currentChannel.IsNews = isNews;
currentChannel.IsSports = isSports;
currentChannel.IsSeries = isSeries;
if (isKids)
{
currentChannel.AddTag("Kids");
}
await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
await currentChannel.RefreshMetadata(
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
ForceSave = true
},
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
}
numComplete++;
double percent = numComplete / (double)allChannelsList.Count;
progress.Report((85 * percent) + 15);
}
progress.Report(100);
return new Tuple<List<Guid>, List<Guid>>(channels, programs);
}
private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken)
{
var list = _itemRepo.GetItemIdsList(new InternalItemsQuery
{
IncludeItemTypes = validTypes,
DtoOptions = new DtoOptions(false)
});
var numComplete = 0;
foreach (var itemId in list)
{
cancellationToken.ThrowIfCancellationRequested();
if (itemId.Equals(default))
{
// Somehow some invalid data got into the db. It probably predates the boundary checking
continue;
}
if (!currentIdList.Contains(itemId))
{
var item = _libraryManager.GetItemById(itemId);
if (item is not null)
{
_libraryManager.DeleteItem(
item,
new DeleteOptions
{
DeleteFileLocation = false,
DeleteFromExternalProvider = false
},
false);
}
}
numComplete++;
double percent = numComplete / (double)list.Count;
progress.Report(100 * percent);
}
}
private async Task<LiveTvChannel> GetChannel(
ChannelInfo channelInfo,
string serviceName,
BaseItem parentFolder,
CancellationToken cancellationToken)
{
var parentFolderId = parentFolder.Id;
var isNew = false;
var forceUpdate = false;
var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id);
if (_libraryManager.GetItemById(id) is not LiveTvChannel item)
{
item = new LiveTvChannel
{
Name = channelInfo.Name,
Id = id,
DateCreated = DateTime.UtcNow
};
isNew = true;
}
if (channelInfo.Tags is not null)
{
if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase))
{
isNew = true;
}
item.Tags = channelInfo.Tags;
}
if (!item.ParentId.Equals(parentFolderId))
{
isNew = true;
}
item.ParentId = parentFolderId;
item.ChannelType = channelInfo.ChannelType;
item.ServiceName = serviceName;
if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase))
{
forceUpdate = true;
}
item.SetProviderId(ExternalServiceTag, serviceName);
if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal))
{
forceUpdate = true;
}
item.ExternalId = channelInfo.Id;
if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal))
{
forceUpdate = true;
}
item.Number = channelInfo.Number;
if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal))
{
forceUpdate = true;
}
item.Name = channelInfo.Name;
if (!item.HasImage(ImageType.Primary))
{
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
{
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
forceUpdate = true;
}
else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
{
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
forceUpdate = true;
}
}
if (isNew)
{
_libraryManager.CreateItem(item, parentFolder);
}
else if (forceUpdate)
{
await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
return item;
}
private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(
ProgramInfo info,
Dictionary<Guid, LiveTvProgram> allExistingPrograms,
LiveTvChannel channel)
{
var id = _tvDtoService.GetInternalProgramId(info.Id);
var isNew = false;
var forceUpdate = false;
if (!allExistingPrograms.TryGetValue(id, out var item))
{
isNew = true;
item = new LiveTvProgram
{
Name = info.Name,
Id = id,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
if (!string.IsNullOrEmpty(info.Etag))
{
item.SetProviderId(EtagKey, info.Etag);
}
}
if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
{
item.ShowId = info.ShowId;
forceUpdate = true;
}
var seriesId = info.SeriesId;
if (!item.ParentId.Equals(channel.Id))
{
forceUpdate = true;
}
item.ParentId = channel.Id;
item.Audio = info.Audio;
item.ChannelId = channel.Id;
item.CommunityRating ??= info.CommunityRating;
if ((item.CommunityRating ?? 0).Equals(0))
{
item.CommunityRating = null;
}
item.EpisodeTitle = info.EpisodeTitle;
item.ExternalId = info.Id;
if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal))
{
forceUpdate = true;
}
item.ExternalSeriesId = seriesId;
var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle))
{
item.SeriesName = info.Name;
}
var tags = new List<string>();
if (info.IsLive)
{
tags.Add("Live");
}
if (info.IsPremiere)
{
tags.Add("Premiere");
}
if (info.IsNews)
{
tags.Add("News");
}
if (info.IsSports)
{
tags.Add("Sports");
}
if (info.IsKids)
{
tags.Add("Kids");
}
if (info.IsRepeat)
{
tags.Add("Repeat");
}
if (info.IsMovie)
{
tags.Add("Movie");
}
if (isSeries)
{
tags.Add("Series");
}
item.Tags = tags.ToArray();
item.Genres = info.Genres.ToArray();
if (info.IsHD ?? false)
{
item.Width = 1280;
item.Height = 720;
}
item.IsMovie = info.IsMovie;
item.IsRepeat = info.IsRepeat;
if (item.IsSeries != isSeries)
{
forceUpdate = true;
}
item.IsSeries = isSeries;
item.Name = info.Name;
item.OfficialRating ??= info.OfficialRating;
item.Overview ??= info.Overview;
item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
item.ProviderIds = info.ProviderIds;
foreach (var providerId in info.SeriesProviderIds)
{
info.ProviderIds["Series" + providerId.Key] = providerId.Value;
}
if (item.StartDate != info.StartDate)
{
forceUpdate = true;
}
item.StartDate = info.StartDate;
if (item.EndDate != info.EndDate)
{
forceUpdate = true;
}
item.EndDate = info.EndDate;
item.ProductionYear = info.ProductionYear;
if (!isSeries || info.IsRepeat)
{
item.PremiereDate = info.OriginalAirDate;
}
item.IndexNumber = info.EpisodeNumber;
item.ParentIndexNumber = info.SeasonNumber;
if (!item.HasImage(ImageType.Primary))
{
if (!string.IsNullOrWhiteSpace(info.ImagePath))
{
item.SetImage(
new ItemImageInfo
{
Path = info.ImagePath,
Type = ImageType.Primary
},
0);
}
else if (!string.IsNullOrWhiteSpace(info.ImageUrl))
{
item.SetImage(
new ItemImageInfo
{
Path = info.ImageUrl,
Type = ImageType.Primary
},
0);
}
}
if (!item.HasImage(ImageType.Thumb))
{
if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl))
{
item.SetImage(
new ItemImageInfo
{
Path = info.ThumbImageUrl,
Type = ImageType.Thumb
},
0);
}
}
if (!item.HasImage(ImageType.Logo))
{
if (!string.IsNullOrWhiteSpace(info.LogoImageUrl))
{
item.SetImage(
new ItemImageInfo
{
Path = info.LogoImageUrl,
Type = ImageType.Logo
},
0);
}
}
if (!item.HasImage(ImageType.Backdrop))
{
if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl))
{
item.SetImage(
new ItemImageInfo
{
Path = info.BackdropImageUrl,
Type = ImageType.Backdrop
},
0);
}
}
var isUpdated = false;
if (isNew)
{
}
else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag))
{
isUpdated = true;
}
else
{
var etag = info.Etag;
if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
{
item.SetProviderId(EtagKey, etag);
isUpdated = true;
}
}
if (isNew || isUpdated)
{
item.OnMetadataChanged();
}
return (item, isNew, isUpdated);
}
}

View File

@ -14,20 +14,16 @@ using Jellyfin.Data.Enums;
using Jellyfin.Data.Events; using Jellyfin.Data.Events;
using Jellyfin.LiveTv.Configuration; using Jellyfin.LiveTv.Configuration;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Progress;
using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Sorting; using MediaBrowser.Controller.Sorting;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Querying; using MediaBrowser.Model.Querying;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
@ -40,24 +36,16 @@ namespace Jellyfin.LiveTv
/// </summary> /// </summary>
public class LiveTvManager : ILiveTvManager public class LiveTvManager : ILiveTvManager
{ {
private const int MaxGuideDays = 14;
private const string ExternalServiceTag = "ExternalServiceId";
private const string EtagKey = "ProgramEtag";
private readonly IServerConfigurationManager _config; private readonly IServerConfigurationManager _config;
private readonly ILogger<LiveTvManager> _logger; private readonly ILogger<LiveTvManager> _logger;
private readonly IItemRepository _itemRepo;
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IDtoService _dtoService; private readonly IDtoService _dtoService;
private readonly IUserDataManager _userDataManager; private readonly IUserDataManager _userDataManager;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly ITaskManager _taskManager; private readonly ITaskManager _taskManager;
private readonly ILocalizationManager _localization; private readonly ILocalizationManager _localization;
private readonly IFileSystem _fileSystem;
private readonly IChannelManager _channelManager; private readonly IChannelManager _channelManager;
private readonly LiveTvDtoService _tvDtoService; private readonly LiveTvDtoService _tvDtoService;
private readonly ITunerHostManager _tunerHostManager;
private ILiveTvService[] _services = Array.Empty<ILiveTvService>(); private ILiveTvService[] _services = Array.Empty<ILiveTvService>();
private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>(); private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>();
@ -65,31 +53,25 @@ namespace Jellyfin.LiveTv
public LiveTvManager( public LiveTvManager(
IServerConfigurationManager config, IServerConfigurationManager config,
ILogger<LiveTvManager> logger, ILogger<LiveTvManager> logger,
IItemRepository itemRepo,
IUserDataManager userDataManager, IUserDataManager userDataManager,
IDtoService dtoService, IDtoService dtoService,
IUserManager userManager, IUserManager userManager,
ILibraryManager libraryManager, ILibraryManager libraryManager,
ITaskManager taskManager, ITaskManager taskManager,
ILocalizationManager localization, ILocalizationManager localization,
IFileSystem fileSystem,
IChannelManager channelManager, IChannelManager channelManager,
LiveTvDtoService liveTvDtoService, LiveTvDtoService liveTvDtoService)
ITunerHostManager tunerHostManager)
{ {
_config = config; _config = config;
_logger = logger; _logger = logger;
_itemRepo = itemRepo;
_userManager = userManager; _userManager = userManager;
_libraryManager = libraryManager; _libraryManager = libraryManager;
_taskManager = taskManager; _taskManager = taskManager;
_localization = localization; _localization = localization;
_fileSystem = fileSystem;
_dtoService = dtoService; _dtoService = dtoService;
_userDataManager = userDataManager; _userDataManager = userDataManager;
_channelManager = channelManager; _channelManager = channelManager;
_tvDtoService = liveTvDtoService; _tvDtoService = liveTvDtoService;
_tunerHostManager = tunerHostManager;
} }
public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled;
@ -400,355 +382,6 @@ namespace Jellyfin.LiveTv
} }
} }
private async Task<LiveTvChannel> GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken)
{
var parentFolderId = parentFolder.Id;
var isNew = false;
var forceUpdate = false;
var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id);
var item = _libraryManager.GetItemById(id) as LiveTvChannel;
if (item is null)
{
item = new LiveTvChannel
{
Name = channelInfo.Name,
Id = id,
DateCreated = DateTime.UtcNow
};
isNew = true;
}
if (channelInfo.Tags is not null)
{
if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase))
{
isNew = true;
}
item.Tags = channelInfo.Tags;
}
if (!item.ParentId.Equals(parentFolderId))
{
isNew = true;
}
item.ParentId = parentFolderId;
item.ChannelType = channelInfo.ChannelType;
item.ServiceName = serviceName;
if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase))
{
forceUpdate = true;
}
item.SetProviderId(ExternalServiceTag, serviceName);
if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal))
{
forceUpdate = true;
}
item.ExternalId = channelInfo.Id;
if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal))
{
forceUpdate = true;
}
item.Number = channelInfo.Number;
if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal))
{
forceUpdate = true;
}
item.Name = channelInfo.Name;
if (!item.HasImage(ImageType.Primary))
{
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
{
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
forceUpdate = true;
}
else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
{
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
forceUpdate = true;
}
}
if (isNew)
{
_libraryManager.CreateItem(item, parentFolder);
}
else if (forceUpdate)
{
await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
return item;
}
private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel)
{
var id = _tvDtoService.GetInternalProgramId(info.Id);
var isNew = false;
var forceUpdate = false;
if (!allExistingPrograms.TryGetValue(id, out LiveTvProgram item))
{
isNew = true;
item = new LiveTvProgram
{
Name = info.Name,
Id = id,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
if (!string.IsNullOrEmpty(info.Etag))
{
item.SetProviderId(EtagKey, info.Etag);
}
}
if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
{
item.ShowId = info.ShowId;
forceUpdate = true;
}
var seriesId = info.SeriesId;
if (!item.ParentId.Equals(channel.Id))
{
forceUpdate = true;
}
item.ParentId = channel.Id;
item.Audio = info.Audio;
item.ChannelId = channel.Id;
item.CommunityRating ??= info.CommunityRating;
if ((item.CommunityRating ?? 0).Equals(0))
{
item.CommunityRating = null;
}
item.EpisodeTitle = info.EpisodeTitle;
item.ExternalId = info.Id;
if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal))
{
forceUpdate = true;
}
item.ExternalSeriesId = seriesId;
var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle))
{
item.SeriesName = info.Name;
}
var tags = new List<string>();
if (info.IsLive)
{
tags.Add("Live");
}
if (info.IsPremiere)
{
tags.Add("Premiere");
}
if (info.IsNews)
{
tags.Add("News");
}
if (info.IsSports)
{
tags.Add("Sports");
}
if (info.IsKids)
{
tags.Add("Kids");
}
if (info.IsRepeat)
{
tags.Add("Repeat");
}
if (info.IsMovie)
{
tags.Add("Movie");
}
if (isSeries)
{
tags.Add("Series");
}
item.Tags = tags.ToArray();
item.Genres = info.Genres.ToArray();
if (info.IsHD ?? false)
{
item.Width = 1280;
item.Height = 720;
}
item.IsMovie = info.IsMovie;
item.IsRepeat = info.IsRepeat;
if (item.IsSeries != isSeries)
{
forceUpdate = true;
}
item.IsSeries = isSeries;
item.Name = info.Name;
item.OfficialRating ??= info.OfficialRating;
item.Overview ??= info.Overview;
item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
item.ProviderIds = info.ProviderIds;
foreach (var providerId in info.SeriesProviderIds)
{
info.ProviderIds["Series" + providerId.Key] = providerId.Value;
}
if (item.StartDate != info.StartDate)
{
forceUpdate = true;
}
item.StartDate = info.StartDate;
if (item.EndDate != info.EndDate)
{
forceUpdate = true;
}
item.EndDate = info.EndDate;
item.ProductionYear = info.ProductionYear;
if (!isSeries || info.IsRepeat)
{
item.PremiereDate = info.OriginalAirDate;
}
item.IndexNumber = info.EpisodeNumber;
item.ParentIndexNumber = info.SeasonNumber;
if (!item.HasImage(ImageType.Primary))
{
if (!string.IsNullOrWhiteSpace(info.ImagePath))
{
item.SetImage(
new ItemImageInfo
{
Path = info.ImagePath,
Type = ImageType.Primary
},
0);
}
else if (!string.IsNullOrWhiteSpace(info.ImageUrl))
{
item.SetImage(
new ItemImageInfo
{
Path = info.ImageUrl,
Type = ImageType.Primary
},
0);
}
}
if (!item.HasImage(ImageType.Thumb))
{
if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl))
{
item.SetImage(
new ItemImageInfo
{
Path = info.ThumbImageUrl,
Type = ImageType.Thumb
},
0);
}
}
if (!item.HasImage(ImageType.Logo))
{
if (!string.IsNullOrWhiteSpace(info.LogoImageUrl))
{
item.SetImage(
new ItemImageInfo
{
Path = info.LogoImageUrl,
Type = ImageType.Logo
},
0);
}
}
if (!item.HasImage(ImageType.Backdrop))
{
if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl))
{
item.SetImage(
new ItemImageInfo
{
Path = info.BackdropImageUrl,
Type = ImageType.Backdrop
},
0);
}
}
var isUpdated = false;
if (isNew)
{
}
else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag))
{
isUpdated = true;
}
else
{
var etag = info.Etag;
if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
{
item.SetProviderId(EtagKey, etag);
isUpdated = true;
}
}
if (isNew || isUpdated)
{
item.OnMetadataChanged();
}
return (item, isNew, isUpdated);
}
public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null) public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null)
{ {
var program = _libraryManager.GetItemById(id); var program = _libraryManager.GetItemById(id);
@ -1000,293 +633,6 @@ namespace Jellyfin.LiveTv
} }
} }
internal Task RefreshChannels(IProgress<double> progress, CancellationToken cancellationToken)
{
return RefreshChannelsInternal(progress, cancellationToken);
}
private async Task RefreshChannelsInternal(IProgress<double> progress, CancellationToken cancellationToken)
{
await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false);
await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
var numComplete = 0;
double progressPerService = _services.Length == 0
? 0
: 1.0 / _services.Length;
var newChannelIdList = new List<Guid>();
var newProgramIdList = new List<Guid>();
var cleanDatabase = true;
foreach (var service in _services)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Refreshing guide from {Name}", service.Name);
try
{
var innerProgress = new ActionableProgress<double>();
innerProgress.RegisterAction(p => progress.Report(p * progressPerService));
var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false);
newChannelIdList.AddRange(idList.Item1);
newProgramIdList.AddRange(idList.Item2);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
cleanDatabase = false;
_logger.LogError(ex, "Error refreshing channels for service");
}
numComplete++;
double percent = numComplete;
percent /= _services.Length;
progress.Report(100 * percent);
}
if (cleanDatabase)
{
CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken);
CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken);
}
var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault();
if (coreService is not null)
{
await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false);
}
// Load these now which will prefetch metadata
var dtoOptions = new DtoOptions();
var fields = dtoOptions.Fields.ToList();
dtoOptions.Fields = fields.ToArray();
progress.Report(100);
}
private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress<double> progress, CancellationToken cancellationToken)
{
progress.Report(10);
var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
.Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
.ToList();
var list = new List<LiveTvChannel>();
var numComplete = 0;
var parentFolder = GetInternalLiveTvFolder(cancellationToken);
foreach (var channelInfo in allChannelsList)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false);
list.Add(item);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
}
numComplete++;
double percent = numComplete;
percent /= allChannelsList.Count;
progress.Report((5 * percent) + 10);
}
progress.Report(15);
numComplete = 0;
var programs = new List<Guid>();
var channels = new List<Guid>();
var guideDays = GetGuideDays();
_logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays);
cancellationToken.ThrowIfCancellationRequested();
foreach (var currentChannel in list)
{
channels.Add(currentChannel.Id);
cancellationToken.ThrowIfCancellationRequested();
try
{
var start = DateTime.UtcNow.AddHours(-1);
var end = start.AddDays(guideDays);
var isMovie = false;
var isSports = false;
var isNews = false;
var isKids = false;
var iSSeries = false;
var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList();
var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
ChannelIds = new Guid[] { currentChannel.Id },
DtoOptions = new DtoOptions(true)
}).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
var newPrograms = new List<LiveTvProgram>();
var updatedPrograms = new List<BaseItem>();
foreach (var program in channelPrograms)
{
var programTuple = GetProgram(program, existingPrograms, currentChannel);
var programItem = programTuple.Item;
if (programTuple.IsNew)
{
newPrograms.Add(programItem);
}
else if (programTuple.IsUpdated)
{
updatedPrograms.Add(programItem);
}
programs.Add(programItem.Id);
isMovie |= program.IsMovie;
iSSeries |= program.IsSeries;
isSports |= program.IsSports;
isNews |= program.IsNews;
isKids |= program.IsKids;
}
_logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count);
if (newPrograms.Count > 0)
{
_libraryManager.CreateItems(newPrograms, null, cancellationToken);
}
if (updatedPrograms.Count > 0)
{
await _libraryManager.UpdateItemsAsync(
updatedPrograms,
currentChannel,
ItemUpdateType.MetadataImport,
cancellationToken).ConfigureAwait(false);
}
currentChannel.IsMovie = isMovie;
currentChannel.IsNews = isNews;
currentChannel.IsSports = isSports;
currentChannel.IsSeries = iSSeries;
if (isKids)
{
currentChannel.AddTag("Kids");
}
await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
await currentChannel.RefreshMetadata(
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
ForceSave = true
},
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
}
numComplete++;
double percent = numComplete / (double)allChannelsList.Count;
progress.Report((85 * percent) + 15);
}
progress.Report(100);
return new Tuple<List<Guid>, List<Guid>>(channels, programs);
}
private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken)
{
var list = _itemRepo.GetItemIdsList(new InternalItemsQuery
{
IncludeItemTypes = validTypes,
DtoOptions = new DtoOptions(false)
});
var numComplete = 0;
foreach (var itemId in list)
{
cancellationToken.ThrowIfCancellationRequested();
if (itemId.Equals(default))
{
// Somehow some invalid data got into the db. It probably predates the boundary checking
continue;
}
if (!currentIdList.Contains(itemId))
{
var item = _libraryManager.GetItemById(itemId);
if (item is not null)
{
_libraryManager.DeleteItem(
item,
new DeleteOptions
{
DeleteFileLocation = false,
DeleteFromExternalProvider = false
},
false);
}
}
numComplete++;
double percent = numComplete / (double)list.Count;
progress.Report(100 * percent);
}
}
private double GetGuideDays()
{
var config = _config.GetLiveTvConfiguration();
if (config.GuideDays.HasValue)
{
return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays));
}
return 7;
}
private async Task<QueryResult<BaseItem>> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user) private async Task<QueryResult<BaseItem>> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user)
{ {
if (user is null) if (user is null)
@ -2056,18 +1402,6 @@ namespace Jellyfin.LiveTv
await service.UpdateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false); await service.UpdateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false);
} }
public GuideInfo GetGuideInfo()
{
var startDate = DateTime.UtcNow;
var endDate = startDate.AddDays(GetGuideDays());
return new GuideInfo
{
StartDate = startDate,
EndDate = endDate
};
}
private LiveTvServiceInfo[] GetServiceInfos() private LiveTvServiceInfo[] GetServiceInfos()
{ {
return Services.Select(GetServiceInfo).ToArray(); return Services.Select(GetServiceInfo).ToArray();

View File

@ -15,16 +15,22 @@ namespace Jellyfin.LiveTv
public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask
{ {
private readonly ILiveTvManager _liveTvManager; private readonly ILiveTvManager _liveTvManager;
private readonly IGuideManager _guideManager;
private readonly IConfigurationManager _config; private readonly IConfigurationManager _config;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class. /// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class.
/// </summary> /// </summary>
/// <param name="liveTvManager">The live tv manager.</param> /// <param name="liveTvManager">The live tv manager.</param>
/// <param name="guideManager">The guide manager.</param>
/// <param name="config">The configuration manager.</param> /// <param name="config">The configuration manager.</param>
public RefreshGuideScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config) public RefreshGuideScheduledTask(
ILiveTvManager liveTvManager,
IGuideManager guideManager,
IConfigurationManager config)
{ {
_liveTvManager = liveTvManager; _liveTvManager = liveTvManager;
_guideManager = guideManager;
_config = config; _config = config;
} }
@ -51,11 +57,7 @@ namespace Jellyfin.LiveTv
/// <inheritdoc /> /// <inheritdoc />
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{ => _guideManager.RefreshGuide(progress, cancellationToken);
var manager = (LiveTvManager)_liveTvManager;
return manager.RefreshChannels(progress, cancellationToken);
}
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()