diff options
| author | Patrick Barron <barronpm@gmail.com> | 2024-01-09 09:54:02 -0500 |
|---|---|---|
| committer | Patrick Barron <barronpm@gmail.com> | 2024-01-09 10:16:58 -0500 |
| commit | 126aa9c893a5b5e3107ca9b6355d354753d45ed3 (patch) | |
| tree | 75040427c791f5815597357c13e830824f9aa3fe /src | |
| parent | c1a3084312fa4fb7796b83640bfe9ad2b5044afa (diff) | |
Move channels to LiveTv project
Diffstat (limited to 'src')
| -rw-r--r-- | src/Jellyfin.LiveTv/Channels/ChannelDynamicMediaSourceProvider.cs | 43 | ||||
| -rw-r--r-- | src/Jellyfin.LiveTv/Channels/ChannelImageProvider.cs | 64 | ||||
| -rw-r--r-- | src/Jellyfin.LiveTv/Channels/ChannelManager.cs | 1257 | ||||
| -rw-r--r-- | src/Jellyfin.LiveTv/Channels/ChannelPostScanTask.cs | 100 | ||||
| -rw-r--r-- | src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs | 88 | ||||
| -rw-r--r-- | src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj | 1 |
6 files changed, 1553 insertions, 0 deletions
diff --git a/src/Jellyfin.LiveTv/Channels/ChannelDynamicMediaSourceProvider.cs b/src/Jellyfin.LiveTv/Channels/ChannelDynamicMediaSourceProvider.cs new file mode 100644 index 000000000..839549ed6 --- /dev/null +++ b/src/Jellyfin.LiveTv/Channels/ChannelDynamicMediaSourceProvider.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.LiveTv.Channels +{ + /// <summary> + /// A media source provider for channels. + /// </summary> + public class ChannelDynamicMediaSourceProvider : IMediaSourceProvider + { + private readonly ChannelManager _channelManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ChannelDynamicMediaSourceProvider"/> class. + /// </summary> + /// <param name="channelManager">The channel manager.</param> + public ChannelDynamicMediaSourceProvider(IChannelManager channelManager) + { + _channelManager = (ChannelManager)channelManager; + } + + /// <inheritdoc /> + public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken) + { + return item.SourceType == SourceType.Channel + ? _channelManager.GetDynamicMediaSources(item, cancellationToken) + : Task.FromResult(Enumerable.Empty<MediaSourceInfo>()); + } + + /// <inheritdoc /> + public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Jellyfin.LiveTv/Channels/ChannelImageProvider.cs b/src/Jellyfin.LiveTv/Channels/ChannelImageProvider.cs new file mode 100644 index 000000000..32e224550 --- /dev/null +++ b/src/Jellyfin.LiveTv/Channels/ChannelImageProvider.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.LiveTv.Channels +{ + /// <summary> + /// An image provider for channels. + /// </summary> + public class ChannelImageProvider : IDynamicImageProvider, IHasItemChangeMonitor + { + private readonly IChannelManager _channelManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ChannelImageProvider"/> class. + /// </summary> + /// <param name="channelManager">The channel manager.</param> + public ChannelImageProvider(IChannelManager channelManager) + { + _channelManager = channelManager; + } + + /// <inheritdoc /> + public string Name => "Channel Image Provider"; + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + return GetChannel(item).GetSupportedChannelImages(); + } + + /// <inheritdoc /> + public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) + { + var channel = GetChannel(item); + + return channel.GetChannelImage(type, cancellationToken); + } + + /// <inheritdoc /> + public bool Supports(BaseItem item) + { + return item is Channel; + } + + private IChannel GetChannel(BaseItem item) + { + var channel = (Channel)item; + + return ((ChannelManager)_channelManager).GetChannelProvider(channel); + } + + /// <inheritdoc /> + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + return GetSupportedImages(item).Any(i => !item.HasImage(i)); + } + } +} diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs new file mode 100644 index 000000000..f5ce75ff4 --- /dev/null +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -0,0 +1,1257 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Channels; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Episode = MediaBrowser.Controller.Entities.TV.Episode; +using Movie = MediaBrowser.Controller.Entities.Movies.Movie; +using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; +using Season = MediaBrowser.Controller.Entities.TV.Season; +using Series = MediaBrowser.Controller.Entities.TV.Series; + +namespace Jellyfin.LiveTv.Channels +{ + /// <summary> + /// The LiveTV channel manager. + /// </summary> + public class ChannelManager : IChannelManager, IDisposable + { + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly IDtoService _dtoService; + private readonly ILibraryManager _libraryManager; + private readonly ILogger<ChannelManager> _logger; + private readonly IServerConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IProviderManager _providerManager; + private readonly IMemoryCache _memoryCache; + private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private bool _disposed = false; + + /// <summary> + /// Initializes a new instance of the <see cref="ChannelManager"/> class. + /// </summary> + /// <param name="userManager">The user manager.</param> + /// <param name="dtoService">The dto service.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="logger">The logger.</param> + /// <param name="config">The server configuration manager.</param> + /// <param name="fileSystem">The filesystem.</param> + /// <param name="userDataManager">The user data manager.</param> + /// <param name="providerManager">The provider manager.</param> + /// <param name="memoryCache">The memory cache.</param> + /// <param name="channels">The channels.</param> + public ChannelManager( + IUserManager userManager, + IDtoService dtoService, + ILibraryManager libraryManager, + ILogger<ChannelManager> logger, + IServerConfigurationManager config, + IFileSystem fileSystem, + IUserDataManager userDataManager, + IProviderManager providerManager, + IMemoryCache memoryCache, + IEnumerable<IChannel> channels) + { + _userManager = userManager; + _dtoService = dtoService; + _libraryManager = libraryManager; + _logger = logger; + _config = config; + _fileSystem = fileSystem; + _userDataManager = userDataManager; + _providerManager = providerManager; + _memoryCache = memoryCache; + Channels = channels.ToArray(); + } + + internal IChannel[] Channels { get; } + + private static TimeSpan CacheLength => TimeSpan.FromHours(3); + + /// <inheritdoc /> + public bool EnableMediaSourceDisplay(BaseItem item) + { + var internalChannel = _libraryManager.GetItemById(item.ChannelId); + var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); + + return channel is not IDisableMediaSourceDisplay; + } + + /// <inheritdoc /> + public bool CanDelete(BaseItem item) + { + var internalChannel = _libraryManager.GetItemById(item.ChannelId); + var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); + + return channel is ISupportsDelete supportsDelete && supportsDelete.CanDelete(item); + } + + /// <inheritdoc /> + public bool EnableMediaProbe(BaseItem item) + { + var internalChannel = _libraryManager.GetItemById(item.ChannelId); + var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); + + return channel is ISupportsMediaProbe; + } + + /// <inheritdoc /> + public Task DeleteItem(BaseItem item) + { + var internalChannel = _libraryManager.GetItemById(item.ChannelId); + if (internalChannel is null) + { + throw new ArgumentException(nameof(item.ChannelId)); + } + + var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); + + if (channel is not ISupportsDelete supportsDelete) + { + throw new ArgumentException(nameof(channel)); + } + + return supportsDelete.DeleteItem(item.ExternalId, CancellationToken.None); + } + + private IEnumerable<IChannel> GetAllChannels() + { + return Channels + .OrderBy(i => i.Name); + } + + /// <summary> + /// Get the installed channel IDs. + /// </summary> + /// <returns>An <see cref="IEnumerable{T}"/> containing installed channel IDs.</returns> + public IEnumerable<Guid> GetInstalledChannelIds() + { + return GetAllChannels().Select(i => GetInternalChannelId(i.Name)); + } + + /// <inheritdoc /> + public async Task<QueryResult<Channel>> GetChannelsInternalAsync(ChannelQuery query) + { + var user = query.UserId.Equals(default) + ? null + : _userManager.GetUserById(query.UserId); + + var channels = await GetAllChannelEntitiesAsync() + .OrderBy(i => i.SortName) + .ToListAsync() + .ConfigureAwait(false); + + if (query.IsRecordingsFolder.HasValue) + { + var val = query.IsRecordingsFolder.Value; + channels = channels.Where(i => + { + try + { + return (GetChannelProvider(i) is IHasFolderAttributes hasAttributes + && hasAttributes.Attributes.Contains("Recordings", StringComparison.OrdinalIgnoreCase)) == val; + } + catch + { + return false; + } + }).ToList(); + } + + if (query.SupportsLatestItems.HasValue) + { + var val = query.SupportsLatestItems.Value; + channels = channels.Where(i => + { + try + { + return GetChannelProvider(i) is ISupportsLatestMedia == val; + } + catch + { + return false; + } + }).ToList(); + } + + if (query.SupportsMediaDeletion.HasValue) + { + var val = query.SupportsMediaDeletion.Value; + channels = channels.Where(i => + { + try + { + return GetChannelProvider(i) is ISupportsDelete == val; + } + catch + { + return false; + } + }).ToList(); + } + + if (query.IsFavorite.HasValue) + { + var val = query.IsFavorite.Value; + channels = channels.Where(i => _userDataManager.GetUserData(user, i).IsFavorite == val) + .ToList(); + } + + if (user is not null) + { + var userId = user.Id.ToString("N", CultureInfo.InvariantCulture); + channels = channels.Where(i => + { + if (!i.IsVisible(user)) + { + return false; + } + + try + { + return GetChannelProvider(i).IsEnabledFor(userId); + } + catch + { + return false; + } + }).ToList(); + } + + var all = channels; + var totalCount = all.Count; + + if (query.StartIndex.HasValue || query.Limit.HasValue) + { + int startIndex = query.StartIndex ?? 0; + int count = query.Limit is null ? totalCount - startIndex : Math.Min(query.Limit.Value, totalCount - startIndex); + all = all.GetRange(startIndex, count); + } + + if (query.RefreshLatestChannelItems) + { + foreach (var item in all) + { + await RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).ConfigureAwait(false); + } + } + + return new QueryResult<Channel>( + query.StartIndex, + totalCount, + all); + } + + /// <inheritdoc /> + public async Task<QueryResult<BaseItemDto>> GetChannelsAsync(ChannelQuery query) + { + var user = query.UserId.Equals(default) + ? null + : _userManager.GetUserById(query.UserId); + + var internalResult = await GetChannelsInternalAsync(query).ConfigureAwait(false); + + var dtoOptions = new DtoOptions(); + + // TODO Fix The co-variant conversion (internalResult.Items) between Folder[] and BaseItem[], this can generate runtime issues. + var returnItems = _dtoService.GetBaseItemDtos(internalResult.Items, dtoOptions, user); + + var result = new QueryResult<BaseItemDto>( + query.StartIndex, + internalResult.TotalRecordCount, + returnItems); + + return result; + } + + /// <summary> + /// Refreshes the associated channels. + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> + /// <returns>The completed task.</returns> + public async Task RefreshChannels(IProgress<double> progress, CancellationToken cancellationToken) + { + var allChannelsList = GetAllChannels().ToList(); + + var numComplete = 0; + + foreach (var channelInfo in allChannelsList) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await GetChannel(channelInfo, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channel information for {0}", channelInfo.Name); + } + + numComplete++; + double percent = (double)numComplete / allChannelsList.Count; + progress.Report(100 * percent); + } + + progress.Report(100); + } + + private async IAsyncEnumerable<Channel> GetAllChannelEntitiesAsync() + { + foreach (IChannel channel in GetAllChannels()) + { + yield return GetChannel(GetInternalChannelId(channel.Name)) ?? await GetChannel(channel, CancellationToken.None).ConfigureAwait(false); + } + } + + private MediaSourceInfo[] GetSavedMediaSources(BaseItem item) + { + var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasourceinfos.json"); + + try + { + var bytes = File.ReadAllBytes(path); + return JsonSerializer.Deserialize<MediaSourceInfo[]>(bytes, _jsonOptions) + ?? Array.Empty<MediaSourceInfo>(); + } + catch + { + return Array.Empty<MediaSourceInfo>(); + } + } + + private async Task SaveMediaSources(BaseItem item, List<MediaSourceInfo> mediaSources) + { + var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasourceinfos.json"); + + if (mediaSources is null || mediaSources.Count == 0) + { + try + { + _fileSystem.DeleteFile(path); + } + catch + { + } + + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + FileStream createStream = File.Create(path); + await using (createStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false); + } + } + + /// <inheritdoc /> + public IEnumerable<MediaSourceInfo> GetStaticMediaSources(BaseItem item, CancellationToken cancellationToken) + { + IEnumerable<MediaSourceInfo> results = GetSavedMediaSources(item); + + return results + .Select(i => NormalizeMediaSource(item, i)) + .ToList(); + } + + /// <summary> + /// Gets the dynamic media sources based on the provided item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> + /// <returns>The task representing the operation to get the media sources.</returns> + public async Task<IEnumerable<MediaSourceInfo>> GetDynamicMediaSources(BaseItem item, CancellationToken cancellationToken) + { + var channel = GetChannel(item.ChannelId); + var channelPlugin = GetChannelProvider(channel); + + IEnumerable<MediaSourceInfo> results; + + if (channelPlugin is IRequiresMediaInfoCallback requiresCallback) + { + results = await GetChannelItemMediaSourcesInternal(requiresCallback, item.ExternalId, cancellationToken) + .ConfigureAwait(false); + } + else + { + results = Enumerable.Empty<MediaSourceInfo>(); + } + + return results + .Select(i => NormalizeMediaSource(item, i)) + .ToList(); + } + + private async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken) + { + if (_memoryCache.TryGetValue(id, out List<MediaSourceInfo> cachedInfo)) + { + return cachedInfo; + } + + var mediaInfo = await channel.GetChannelItemMediaInfo(id, cancellationToken) + .ConfigureAwait(false); + var list = mediaInfo.ToList(); + _memoryCache.Set(id, list, DateTimeOffset.UtcNow.AddMinutes(5)); + + return list; + } + + private static MediaSourceInfo NormalizeMediaSource(BaseItem item, MediaSourceInfo info) + { + info.RunTimeTicks ??= item.RunTimeTicks; + + return info; + } + + private async Task<Channel> GetChannel(IChannel channelInfo, CancellationToken cancellationToken) + { + var parentFolderId = Guid.Empty; + + var id = GetInternalChannelId(channelInfo.Name); + + var path = Channel.GetInternalMetadataPath(_config.ApplicationPaths.InternalMetadataPath, id); + + var isNew = false; + var forceUpdate = false; + + var item = _libraryManager.GetItemById(id) as Channel; + + if (item is null) + { + item = new Channel + { + Name = channelInfo.Name, + Id = id, + DateCreated = _fileSystem.GetCreationTimeUtc(path), + DateModified = _fileSystem.GetLastWriteTimeUtc(path) + }; + + isNew = true; + } + + if (!string.Equals(item.Path, path, StringComparison.OrdinalIgnoreCase)) + { + isNew = true; + } + + item.Path = path; + + if (!item.ChannelId.Equals(id)) + { + forceUpdate = true; + } + + item.ChannelId = id; + + if (!item.ParentId.Equals(parentFolderId)) + { + forceUpdate = true; + } + + item.ParentId = parentFolderId; + + item.OfficialRating = GetOfficialRating(channelInfo.ParentalRating); + item.Overview = channelInfo.Description; + + if (string.IsNullOrWhiteSpace(item.Name)) + { + item.Name = channelInfo.Name; + } + + if (isNew) + { + item.OnMetadataChanged(); + _libraryManager.CreateItem(item, null); + } + + await item.RefreshMetadata( + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = !isNew && forceUpdate + }, + cancellationToken).ConfigureAwait(false); + + return item; + } + + private static string GetOfficialRating(ChannelParentalRating rating) + { + return rating switch + { + ChannelParentalRating.Adult => "XXX", + ChannelParentalRating.UsR => "R", + ChannelParentalRating.UsPG13 => "PG-13", + ChannelParentalRating.UsPG => "PG", + _ => null + }; + } + + /// <summary> + /// Gets a channel with the provided Guid. + /// </summary> + /// <param name="id">The Guid.</param> + /// <returns>The corresponding channel.</returns> + public Channel GetChannel(Guid id) + { + return _libraryManager.GetItemById(id) as Channel; + } + + /// <inheritdoc /> + public Channel GetChannel(string id) + { + return _libraryManager.GetItemById(id) as Channel; + } + + /// <inheritdoc /> + public ChannelFeatures[] GetAllChannelFeatures() + { + return _libraryManager.GetItemIds( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Channel }, + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } + }).Select(i => GetChannelFeatures(i)).ToArray(); + } + + /// <inheritdoc /> + public ChannelFeatures GetChannelFeatures(Guid? id) + { + if (!id.HasValue) + { + throw new ArgumentNullException(nameof(id)); + } + + var channel = GetChannel(id.Value); + var channelProvider = GetChannelProvider(channel); + + return GetChannelFeaturesDto(channel, channelProvider, channelProvider.GetChannelFeatures()); + } + + /// <summary> + /// Checks whether the provided Guid supports external transfer. + /// </summary> + /// <param name="channelId">The Guid.</param> + /// <returns>Whether or not the provided Guid supports external transfer.</returns> + public bool SupportsExternalTransfer(Guid channelId) + { + var channelProvider = GetChannelProvider(channelId); + + return channelProvider.GetChannelFeatures().SupportsContentDownloading; + } + + /// <summary> + /// Gets the provided channel's supported features. + /// </summary> + /// <param name="channel">The channel.</param> + /// <param name="provider">The provider.</param> + /// <param name="features">The features.</param> + /// <returns>The supported features.</returns> + public ChannelFeatures GetChannelFeaturesDto( + Channel channel, + IChannel provider, + InternalChannelFeatures features) + { + var supportsLatest = provider is ISupportsLatestMedia; + + return new ChannelFeatures(channel.Name, channel.Id) + { + CanFilter = !features.MaxPageSize.HasValue, + CanSearch = provider is ISearchableChannel, + ContentTypes = features.ContentTypes.ToArray(), + DefaultSortFields = features.DefaultSortFields.ToArray(), + MaxPageSize = features.MaxPageSize, + MediaTypes = features.MediaTypes.ToArray(), + SupportsSortOrderToggle = features.SupportsSortOrderToggle, + SupportsLatestMedia = supportsLatest, + SupportsContentDownloading = features.SupportsContentDownloading, + AutoRefreshLevels = features.AutoRefreshLevels + }; + } + + private Guid GetInternalChannelId(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return _libraryManager.GetNewItemId("Channel " + name, typeof(Channel)); + } + + /// <inheritdoc /> + public async Task<QueryResult<BaseItemDto>> GetLatestChannelItems(InternalItemsQuery query, CancellationToken cancellationToken) + { + var internalResult = await GetLatestChannelItemsInternal(query, cancellationToken).ConfigureAwait(false); + + var items = internalResult.Items; + var totalRecordCount = internalResult.TotalRecordCount; + + var returnItems = _dtoService.GetBaseItemDtos(items, query.DtoOptions, query.User); + + var result = new QueryResult<BaseItemDto>( + query.StartIndex, + totalRecordCount, + returnItems); + + return result; + } + + /// <inheritdoc /> + public async Task<QueryResult<BaseItem>> GetLatestChannelItemsInternal(InternalItemsQuery query, CancellationToken cancellationToken) + { + var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray(); + + if (query.ChannelIds.Count > 0) + { + // Avoid implicitly captured closure + var ids = query.ChannelIds; + channels = channels + .Where(i => ids.Contains(GetInternalChannelId(i.Name))) + .ToArray(); + } + + if (channels.Length == 0) + { + return new QueryResult<BaseItem>(); + } + + foreach (var channel in channels) + { + await RefreshLatestChannelItems(channel, cancellationToken).ConfigureAwait(false); + } + + query.IsFolder = false; + + // hack for trailers, figure out a better way later + var sortByPremiereDate = channels.Length == 1 && channels[0].GetType().Name.Contains("Trailer", StringComparison.Ordinal); + + if (sortByPremiereDate) + { + query.OrderBy = new[] + { + (ItemSortBy.PremiereDate, SortOrder.Descending), + (ItemSortBy.ProductionYear, SortOrder.Descending), + (ItemSortBy.DateCreated, SortOrder.Descending) + }; + } + else + { + query.OrderBy = new[] + { + (ItemSortBy.DateCreated, SortOrder.Descending) + }; + } + + return _libraryManager.GetItemsResult(query); + } + + private async Task RefreshLatestChannelItems(IChannel channel, CancellationToken cancellationToken) + { + var internalChannel = await GetChannel(channel, cancellationToken).ConfigureAwait(false); + + var query = new InternalItemsQuery + { + Parent = internalChannel, + EnableTotalRecordCount = false, + ChannelIds = new Guid[] { internalChannel.Id } + }; + + var result = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + + foreach (var item in result.Items) + { + if (item is Folder folder) + { + await GetChannelItemsInternal( + new InternalItemsQuery + { + Parent = folder, + EnableTotalRecordCount = false, + ChannelIds = new Guid[] { internalChannel.Id } + }, + new SimpleProgress<double>(), + cancellationToken).ConfigureAwait(false); + } + } + } + + /// <inheritdoc /> + public async Task<QueryResult<BaseItem>> GetChannelItemsInternal(InternalItemsQuery query, IProgress<double> progress, CancellationToken cancellationToken) + { + // Get the internal channel entity + var channel = GetChannel(query.ChannelIds[0]); + + // Find the corresponding channel provider plugin + var channelProvider = GetChannelProvider(channel); + + var parentItem = query.ParentId.Equals(default) + ? channel + : _libraryManager.GetItemById(query.ParentId); + + var itemsResult = await GetChannelItems( + channelProvider, + query.User, + parentItem is Channel ? null : parentItem.ExternalId, + null, + false, + cancellationToken) + .ConfigureAwait(false); + + if (query.ParentId.Equals(default)) + { + query.Parent = channel; + } + + query.ChannelIds = Array.Empty<Guid>(); + + // Not yet sure why this is causing a problem + query.GroupByPresentationUniqueKey = false; + + // null if came from cache + if (itemsResult is not null) + { + var items = itemsResult.Items; + var itemsLen = items.Count; + var internalItems = new Guid[itemsLen]; + for (int i = 0; i < itemsLen; i++) + { + internalItems[i] = (await GetChannelItemEntityAsync( + items[i], + channelProvider, + channel.Id, + parentItem, + cancellationToken).ConfigureAwait(false)).Id; + } + + var existingIds = _libraryManager.GetItemIds(query); + var deadIds = existingIds.Except(internalItems) + .ToArray(); + + foreach (var deadId in deadIds) + { + var deadItem = _libraryManager.GetItemById(deadId); + if (deadItem is not null) + { + _libraryManager.DeleteItem( + deadItem, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + parentItem, + false); + } + } + } + + return _libraryManager.GetItemsResult(query); + } + + /// <inheritdoc /> + public async Task<QueryResult<BaseItemDto>> GetChannelItems(InternalItemsQuery query, CancellationToken cancellationToken) + { + var internalResult = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + + var returnItems = _dtoService.GetBaseItemDtos(internalResult.Items, query.DtoOptions, query.User); + + var result = new QueryResult<BaseItemDto>( + query.StartIndex, + internalResult.TotalRecordCount, + returnItems); + + return result; + } + + private async Task<ChannelItemResult> GetChannelItems( + IChannel channel, + User user, + string externalFolderId, + ChannelItemSortField? sortField, + bool sortDescending, + CancellationToken cancellationToken) + { + var userId = user?.Id.ToString("N", CultureInfo.InvariantCulture); + + var cacheLength = CacheLength; + var cachePath = GetChannelDataCachePath(channel, userId, externalFolderId, sortField, sortDescending); + + try + { + if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) + { + var jsonStream = AsyncFile.OpenRead(cachePath); + await using (jsonStream.ConfigureAwait(false)) + { + var cachedResult = await JsonSerializer + .DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken) + .ConfigureAwait(false); + if (cachedResult is not null) + { + return null; + } + } + } + } + catch (FileNotFoundException) + { + } + catch (IOException) + { + } + + await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + try + { + if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) + { + var jsonStream = AsyncFile.OpenRead(cachePath); + await using (jsonStream.ConfigureAwait(false)) + { + var cachedResult = await JsonSerializer + .DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken) + .ConfigureAwait(false); + if (cachedResult is not null) + { + return null; + } + } + } + } + catch (FileNotFoundException) + { + } + catch (IOException) + { + } + + var query = new InternalChannelItemQuery + { + UserId = user?.Id ?? Guid.Empty, + SortBy = sortField, + SortDescending = sortDescending, + FolderId = externalFolderId + }; + + query.FolderId = externalFolderId; + + var result = await channel.GetChannelItems(query, cancellationToken).ConfigureAwait(false); + + if (result is null) + { + throw new InvalidOperationException("Channel returned a null result from GetChannelItems"); + } + + await CacheResponse(result, cachePath).ConfigureAwait(false); + + return result; + } + finally + { + _resourcePool.Release(); + } + } + + private async Task CacheResponse(ChannelItemResult result, string path) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + var createStream = File.Create(path); + await using (createStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(createStream, result, _jsonOptions).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error writing to channel cache file: {Path}", path); + } + } + + private string GetChannelDataCachePath( + IChannel channel, + string userId, + string externalFolderId, + ChannelItemSortField? sortField, + bool sortDescending) + { + var channelId = GetInternalChannelId(channel.Name).ToString("N", CultureInfo.InvariantCulture); + + var userCacheKey = string.Empty; + + if (channel is IHasCacheKey hasCacheKey) + { + userCacheKey = hasCacheKey.GetCacheKey(userId) ?? string.Empty; + } + + var filename = string.IsNullOrEmpty(externalFolderId) ? "root" : externalFolderId.GetMD5().ToString("N", CultureInfo.InvariantCulture); + filename += userCacheKey; + + var version = ((channel.DataVersion ?? string.Empty) + "2").GetMD5().ToString("N", CultureInfo.InvariantCulture); + + if (sortField.HasValue) + { + filename += "-sortField-" + sortField.Value; + } + + if (sortDescending) + { + filename += "-sortDescending"; + } + + filename = filename.GetMD5().ToString("N", CultureInfo.InvariantCulture); + + return Path.Combine( + _config.ApplicationPaths.CachePath, + "channels", + channelId, + version, + filename + ".json"); + } + + private static string GetIdToHash(string externalId, string channelName) + { + // Increment this as needed to force new downloads + // Incorporate Name because it's being used to convert channel entity to provider + return externalId + (channelName ?? string.Empty) + "16"; + } + + private T GetItemById<T>(string idString, string channelName, out bool isNew) + where T : BaseItem, new() + { + var id = _libraryManager.GetNewItemId(GetIdToHash(idString, channelName), typeof(T)); + + T item = null; + + try + { + item = _libraryManager.GetItemById(id) as T; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving channel item from database"); + } + + if (item is null) + { + item = new T(); + isNew = true; + } + else + { + isNew = false; + } + + item.Id = id; + return item; + } + + private async Task<BaseItem> GetChannelItemEntityAsync(ChannelItemInfo info, IChannel channelProvider, Guid internalChannelId, BaseItem parentFolder, CancellationToken cancellationToken) + { + var parentFolderId = parentFolder.Id; + + BaseItem item; + bool isNew; + bool forceUpdate = false; + + if (info.Type == ChannelItemType.Folder) + { + item = info.FolderType switch + { + ChannelFolderType.MusicAlbum => GetItemById<MusicAlbum>(info.Id, channelProvider.Name, out isNew), + ChannelFolderType.MusicArtist => GetItemById<MusicArtist>(info.Id, channelProvider.Name, out isNew), + ChannelFolderType.PhotoAlbum => GetItemById<PhotoAlbum>(info.Id, channelProvider.Name, out isNew), + ChannelFolderType.Series => GetItemById<Series>(info.Id, channelProvider.Name, out isNew), + ChannelFolderType.Season => GetItemById<Season>(info.Id, channelProvider.Name, out isNew), + _ => GetItemById<Folder>(info.Id, channelProvider.Name, out isNew) + }; + } + else if (info.MediaType == ChannelMediaType.Audio) + { + item = info.ContentType == ChannelMediaContentType.Podcast + ? GetItemById<AudioBook>(info.Id, channelProvider.Name, out isNew) + : GetItemById<Audio>(info.Id, channelProvider.Name, out isNew); + } + else + { + item = info.ContentType switch + { + ChannelMediaContentType.Episode => GetItemById<Episode>(info.Id, channelProvider.Name, out isNew), + ChannelMediaContentType.Movie => GetItemById<Movie>(info.Id, channelProvider.Name, out isNew), + var x when x == ChannelMediaContentType.Trailer || info.ExtraType == ExtraType.Trailer + => GetItemById<Trailer>(info.Id, channelProvider.Name, out isNew), + _ => GetItemById<Video>(info.Id, channelProvider.Name, out isNew) + }; + } + + var enableMediaProbe = channelProvider is ISupportsMediaProbe; + + if (info.IsLiveStream) + { + item.RunTimeTicks = null; + } + else if (isNew || !enableMediaProbe) + { + item.RunTimeTicks = info.RunTimeTicks; + } + + if (isNew) + { + item.Name = info.Name; + item.Genres = info.Genres.ToArray(); + item.Studios = info.Studios.ToArray(); + item.CommunityRating = info.CommunityRating; + item.Overview = info.Overview; + item.IndexNumber = info.IndexNumber; + item.ParentIndexNumber = info.ParentIndexNumber; + item.PremiereDate = info.PremiereDate; + item.ProductionYear = info.ProductionYear; + item.ProviderIds = info.ProviderIds; + item.OfficialRating = info.OfficialRating; + item.DateCreated = info.DateCreated ?? DateTime.UtcNow; + item.Tags = info.Tags.ToArray(); + item.OriginalTitle = info.OriginalTitle; + } + else if (info.Type == ChannelItemType.Folder && info.FolderType == ChannelFolderType.Container) + { + // At least update names of container folders + if (item.Name != info.Name) + { + item.Name = info.Name; + forceUpdate = true; + } + } + + if (item is IHasArtist hasArtists) + { + hasArtists.Artists = info.Artists.ToArray(); + } + + if (item is IHasAlbumArtist hasAlbumArtists) + { + hasAlbumArtists.AlbumArtists = info.AlbumArtists.ToArray(); + } + + if (item is Trailer trailer) + { + if (!info.TrailerTypes.SequenceEqual(trailer.TrailerTypes)) + { + _logger.LogDebug("Forcing update due to TrailerTypes {0}", item.Name); + forceUpdate = true; + } + + trailer.TrailerTypes = info.TrailerTypes.ToArray(); + } + + if (info.DateModified > item.DateModified) + { + item.DateModified = info.DateModified; + _logger.LogDebug("Forcing update due to DateModified {0}", item.Name); + forceUpdate = true; + } + + if (!internalChannelId.Equals(item.ChannelId)) + { + forceUpdate = true; + _logger.LogDebug("Forcing update due to ChannelId {0}", item.Name); + } + + item.ChannelId = internalChannelId; + + if (!item.ParentId.Equals(parentFolderId)) + { + forceUpdate = true; + _logger.LogDebug("Forcing update due to parent folder Id {0}", item.Name); + } + + item.ParentId = parentFolderId; + + if (item is IHasSeries hasSeries) + { + if (!string.Equals(hasSeries.SeriesName, info.SeriesName, StringComparison.OrdinalIgnoreCase)) + { + forceUpdate = true; + _logger.LogDebug("Forcing update due to SeriesName {0}", item.Name); + } + + hasSeries.SeriesName = info.SeriesName; + } + + if (!string.Equals(item.ExternalId, info.Id, StringComparison.OrdinalIgnoreCase)) + { + forceUpdate = true; + _logger.LogDebug("Forcing update due to ExternalId {0}", item.Name); + } + + item.ExternalId = info.Id; + + if (item is Audio channelAudioItem) + { + channelAudioItem.ExtraType = info.ExtraType; + + var mediaSource = info.MediaSources.FirstOrDefault(); + item.Path = mediaSource?.Path; + } + + if (item is Video channelVideoItem) + { + channelVideoItem.ExtraType = info.ExtraType; + + var mediaSource = info.MediaSources.FirstOrDefault(); + item.Path = mediaSource?.Path; + } + + if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary)) + { + item.SetImagePath(ImageType.Primary, info.ImageUrl); + _logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name); + forceUpdate = true; + } + + if (!info.IsLiveStream) + { + if (item.Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase)) + { + item.Tags = item.Tags.Except(new[] { "livestream" }, StringComparer.OrdinalIgnoreCase).ToArray(); + _logger.LogDebug("Forcing update due to Tags {0}", item.Name); + forceUpdate = true; + } + } + else + { + if (!item.Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase)) + { + item.Tags = item.Tags.Concat(new[] { "livestream" }).ToArray(); + _logger.LogDebug("Forcing update due to Tags {0}", item.Name); + forceUpdate = true; + } + } + + item.OnMetadataChanged(); + + if (isNew) + { + _libraryManager.CreateItem(item, parentFolder); + + if (info.People is not null && info.People.Count > 0) + { + await _libraryManager.UpdatePeopleAsync(item, info.People, cancellationToken).ConfigureAwait(false); + } + } + else if (forceUpdate) + { + await item.UpdateToRepositoryAsync(ItemUpdateType.None, cancellationToken).ConfigureAwait(false); + } + + if ((isNew || forceUpdate) && info.Type == ChannelItemType.Media) + { + if (enableMediaProbe && !info.IsLiveStream && item.HasPathProtocol) + { + await SaveMediaSources(item, new List<MediaSourceInfo>()).ConfigureAwait(false); + } + else + { + await SaveMediaSources(item, info.MediaSources).ConfigureAwait(false); + } + } + + if (isNew || forceUpdate || item.DateLastRefreshed == default) + { + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.Normal); + } + + return item; + } + + internal IChannel GetChannelProvider(Channel channel) + { + ArgumentNullException.ThrowIfNull(channel); + + var result = GetAllChannels() + .FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(channel.ChannelId) || string.Equals(i.Name, channel.Name, StringComparison.OrdinalIgnoreCase)); + + if (result is null) + { + throw new ResourceNotFoundException("No channel provider found for channel " + channel.Name); + } + + return result; + } + + internal IChannel GetChannelProvider(Guid internalChannelId) + { + var result = GetAllChannels() + .FirstOrDefault(i => internalChannelId.Equals(GetInternalChannelId(i.Name))); + + if (result is null) + { + throw new ResourceNotFoundException("No channel provider found for channel id " + internalChannelId); + } + + return result; + } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and optionally managed resources. + /// </summary> + /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _resourcePool?.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/src/Jellyfin.LiveTv/Channels/ChannelPostScanTask.cs b/src/Jellyfin.LiveTv/Channels/ChannelPostScanTask.cs new file mode 100644 index 000000000..b4f6cf731 --- /dev/null +++ b/src/Jellyfin.LiveTv/Channels/ChannelPostScanTask.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Channels +{ + /// <summary> + /// A task to remove all non-installed channels from the database. + /// </summary> + public class ChannelPostScanTask + { + private readonly IChannelManager _channelManager; + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ChannelPostScanTask"/> class. + /// </summary> + /// <param name="channelManager">The channel manager.</param> + /// <param name="logger">The logger.</param> + /// <param name="libraryManager">The library manager.</param> + public ChannelPostScanTask(IChannelManager channelManager, ILogger logger, ILibraryManager libraryManager) + { + _channelManager = channelManager; + _logger = logger; + _libraryManager = libraryManager; + } + + /// <summary> + /// Runs this task. + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The completed task.</returns> + public Task Run(IProgress<double> progress, CancellationToken cancellationToken) + { + CleanDatabase(cancellationToken); + + progress.Report(100); + return Task.CompletedTask; + } + + private void CleanDatabase(CancellationToken cancellationToken) + { + var installedChannelIds = ((ChannelManager)_channelManager).GetInstalledChannelIds(); + + var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Channel }, + ExcludeItemIds = installedChannelIds.ToArray() + }); + + foreach (var channel in uninstalledChannels) + { + cancellationToken.ThrowIfCancellationRequested(); + + CleanChannel((Channel)channel, cancellationToken); + } + } + + private void CleanChannel(Channel channel, CancellationToken cancellationToken) + { + _logger.LogInformation("Cleaning channel {0} from database", channel.Id); + + // Delete all channel items + var items = _libraryManager.GetItemList(new InternalItemsQuery + { + ChannelIds = new[] { channel.Id } + }); + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false + }, + false); + } + + // Finally, delete the channel itself + _libraryManager.DeleteItem( + channel, + new DeleteOptions + { + DeleteFileLocation = false + }, + false); + } + } +} diff --git a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs new file mode 100644 index 000000000..556e052d4 --- /dev/null +++ b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Channels +{ + /// <summary> + /// The "Refresh Channels" scheduled task. + /// </summary> + public class RefreshChannelsScheduledTask : IScheduledTask, IConfigurableScheduledTask + { + private readonly IChannelManager _channelManager; + private readonly ILogger<RefreshChannelsScheduledTask> _logger; + private readonly ILibraryManager _libraryManager; + private readonly ILocalizationManager _localization; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshChannelsScheduledTask"/> class. + /// </summary> + /// <param name="channelManager">The channel manager.</param> + /// <param name="logger">The logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="localization">The localization manager.</param> + public RefreshChannelsScheduledTask( + IChannelManager channelManager, + ILogger<RefreshChannelsScheduledTask> logger, + ILibraryManager libraryManager, + ILocalizationManager localization) + { + _channelManager = channelManager; + _logger = logger; + _libraryManager = libraryManager; + _localization = localization; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TasksRefreshChannels"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TasksRefreshChannelsDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksChannelsCategory"); + + /// <inheritdoc /> + public bool IsHidden => ((ChannelManager)_channelManager).Channels.Length == 0; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <inheritdoc /> + public string Key => "RefreshInternetChannels"; + + /// <inheritdoc /> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var manager = (ChannelManager)_channelManager; + + await manager.RefreshChannels(new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + + await new ChannelPostScanTask(_channelManager, _logger, _libraryManager).Run(progress, cancellationToken) + .ConfigureAwait(false); + } + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] + { + // Every so often + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks + } + }; + } + } +} diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj index 391006449..5a826a1da 100644 --- a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj +++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj @@ -12,6 +12,7 @@ <ItemGroup> <PackageReference Include="Jellyfin.XmlTv" /> + <PackageReference Include="System.Linq.Async" /> </ItemGroup> <ItemGroup> |
