#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.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 Emby.Server.Implementations.Channels { /// /// The LiveTV channel manager. /// public class ChannelManager : IChannelManager { private readonly IUserManager _userManager; private readonly IUserDataManager _userDataManager; private readonly IDtoService _dtoService; private readonly ILibraryManager _libraryManager; private readonly ILogger _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; /// /// Initializes a new instance of the class. /// /// The user manager. /// The dto service. /// The library manager. /// The logger. /// The server configuration manager. /// The filesystem. /// The user data manager. /// The provider manager. /// The memory cache. public ChannelManager( IUserManager userManager, IDtoService dtoService, ILibraryManager libraryManager, ILogger logger, IServerConfigurationManager config, IFileSystem fileSystem, IUserDataManager userDataManager, IProviderManager providerManager, IMemoryCache memoryCache) { _userManager = userManager; _dtoService = dtoService; _libraryManager = libraryManager; _logger = logger; _config = config; _fileSystem = fileSystem; _userDataManager = userDataManager; _providerManager = providerManager; _memoryCache = memoryCache; } internal IChannel[] Channels { get; private set; } private static TimeSpan CacheLength => TimeSpan.FromHours(3); /// public void AddParts(IEnumerable channels) { Channels = channels.ToArray(); } /// 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; } /// 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); } /// 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; } /// public Task DeleteItem(BaseItem item) { var internalChannel = _libraryManager.GetItemById(item.ChannelId); if (internalChannel == null) { throw new ArgumentException(); } var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); var supportsDelete = channel as ISupportsDelete; if (supportsDelete == null) { throw new ArgumentException(); } return supportsDelete.DeleteItem(item.ExternalId, CancellationToken.None); } private IEnumerable GetAllChannels() { return Channels .OrderBy(i => i.Name); } /// /// Get the installed channel IDs. /// /// An containing installed channel IDs. public IEnumerable GetInstalledChannelIds() { return GetAllChannels().Select(i => GetInternalChannelId(i.Name)); } /// public QueryResult GetChannelsInternal(ChannelQuery query) { var user = query.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(query.UserId); var channels = GetAllChannels() .Select(GetChannelEntity) .OrderBy(i => i.SortName) .ToList(); if (query.IsRecordingsFolder.HasValue) { var val = query.IsRecordingsFolder.Value; channels = channels.Where(i => { try { return (GetChannelProvider(i) is IHasFolderAttributes hasAttributes && hasAttributes.Attributes.Contains("Recordings", StringComparer.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 != null) { channels = channels.Where(i => { if (!i.IsVisible(user)) { return false; } try { return GetChannelProvider(i).IsEnabledFor(user.Id.ToString("N", CultureInfo.InvariantCulture)); } 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 == null ? totalCount - startIndex : Math.Min(query.Limit.Value, totalCount - startIndex); all = all.GetRange(startIndex, count); } if (query.RefreshLatestChannelItems) { foreach (var item in all) { RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult(); } } return new QueryResult { Items = all, TotalRecordCount = totalCount }; } /// public QueryResult GetChannels(ChannelQuery query) { var user = query.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(query.UserId); var internalResult = GetChannelsInternal(query); 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 { Items = returnItems, TotalRecordCount = internalResult.TotalRecordCount }; return result; } /// /// Refreshes the associated channels. /// /// The progress. /// A cancellation token that can be used to cancel the operation. /// The completed task. public async Task RefreshChannels(IProgress 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 Channel GetChannelEntity(IChannel channel) { return GetChannel(GetInternalChannelId(channel.Name)) ?? GetChannel(channel, CancellationToken.None).Result; } private MediaSourceInfo[] GetSavedMediaSources(BaseItem item) { var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasourceinfos.json"); try { var bytes = File.ReadAllBytes(path); return JsonSerializer.Deserialize(bytes, _jsonOptions) ?? Array.Empty(); } catch { return Array.Empty(); } } private async Task SaveMediaSources(BaseItem item, List mediaSources) { var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasourceinfos.json"); if (mediaSources == null || mediaSources.Count == 0) { try { _fileSystem.DeleteFile(path); } catch { } return; } Directory.CreateDirectory(Path.GetDirectoryName(path)); await using FileStream createStream = File.Create(path); await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false); } /// public IEnumerable GetStaticMediaSources(BaseItem item, CancellationToken cancellationToken) { IEnumerable results = GetSavedMediaSources(item); return results .Select(i => NormalizeMediaSource(item, i)) .ToList(); } /// /// Gets the dynamic media sources based on the provided item. /// /// The item. /// A cancellation token that can be used to cancel the operation. /// The task representing the operation to get the media sources. public async Task> GetDynamicMediaSources(BaseItem item, CancellationToken cancellationToken) { var channel = GetChannel(item.ChannelId); var channelPlugin = GetChannelProvider(channel); IEnumerable results; if (channelPlugin is IRequiresMediaInfoCallback requiresCallback) { results = await GetChannelItemMediaSourcesInternal(requiresCallback, item.ExternalId, cancellationToken) .ConfigureAwait(false); } else { results = new List(); } return results .Select(i => NormalizeMediaSource(item, i)) .ToList(); } private async Task> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken) { if (_memoryCache.TryGetValue(id, out List 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 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 == 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 != 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 }; } /// /// Gets a channel with the provided Guid. /// /// The Guid. /// The corresponding channel. public Channel GetChannel(Guid id) { return _libraryManager.GetItemById(id) as Channel; } /// public Channel GetChannel(string id) { return _libraryManager.GetItemById(id) as Channel; } /// public ChannelFeatures[] GetAllChannelFeatures() { return _libraryManager.GetItemIds( new InternalItemsQuery { IncludeItemTypes = new[] { nameof(Channel) }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } }).Select(i => GetChannelFeatures(i)).ToArray(); } /// 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()); } /// /// Checks whether the provided Guid supports external transfer. /// /// The Guid. /// Whether or not the provided Guid supports external transfer. public bool SupportsExternalTransfer(Guid channelId) { var channelProvider = GetChannelProvider(channelId); return channelProvider.GetChannelFeatures().SupportsContentDownloading; } /// /// Gets the provided channel's supported features. /// /// The channel. /// The provider. /// The features. /// The supported features. 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) { if (string.IsNullOrEmpty(name)) { throw new ArgumentNullException(nameof(name)); } return _libraryManager.GetNewItemId("Channel " + name, typeof(Channel)); } /// public async Task> 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 { Items = returnItems, TotalRecordCount = totalRecordCount }; return result; } /// public async Task> 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(); } 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(), 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(), cancellationToken).ConfigureAwait(false); } } } /// public async Task> GetChannelItemsInternal(InternalItemsQuery query, IProgress 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 == Guid.Empty ? 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 == Guid.Empty) { query.Parent = channel; } query.ChannelIds = Array.Empty(); // Not yet sure why this is causing a problem query.GroupByPresentationUniqueKey = false; // null if came from cache if (itemsResult != 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 != null) { _libraryManager.DeleteItem( deadItem, new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false }, parentItem, false); } } } return _libraryManager.GetItemsResult(query); } /// public async Task> GetChannelItems(InternalItemsQuery query, CancellationToken cancellationToken) { var internalResult = await GetChannelItemsInternal(query, new SimpleProgress(), cancellationToken).ConfigureAwait(false); var returnItems = _dtoService.GetBaseItemDtos(internalResult.Items, query.DtoOptions, query.User); var result = new QueryResult { Items = returnItems, TotalRecordCount = internalResult.TotalRecordCount }; return result; } private async Task 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) { await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); var cachedResult = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { return null; } } } catch (FileNotFoundException) { } catch (IOException) { } await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); try { try { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); var cachedResult = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != 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 == null) { throw new InvalidOperationException("Channel returned a null result from GetChannelItems"); } await CacheResponse(result, cachePath); return result; } finally { _resourcePool.Release(); } } private async Task CacheResponse(ChannelItemResult result, string path) { try { Directory.CreateDirectory(Path.GetDirectoryName(path)); await using FileStream createStream = File.Create(path); 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(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 == null) { item = new T(); isNew = true; } else { isNew = false; } item.Id = id; return item; } private async Task 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(info.Id, channelProvider.Name, out isNew), ChannelFolderType.MusicArtist => GetItemById(info.Id, channelProvider.Name, out isNew), ChannelFolderType.PhotoAlbum => GetItemById(info.Id, channelProvider.Name, out isNew), ChannelFolderType.Series => GetItemById(info.Id, channelProvider.Name, out isNew), ChannelFolderType.Season => GetItemById(info.Id, channelProvider.Name, out isNew), _ => GetItemById(info.Id, channelProvider.Name, out isNew) }; } else if (info.MediaType == ChannelMediaType.Audio) { item = info.ContentType == ChannelMediaContentType.Podcast ? GetItemById(info.Id, channelProvider.Name, out isNew) : GetItemById