aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorPatrick Barron <barronpm@gmail.com>2024-01-09 09:54:02 -0500
committerPatrick Barron <barronpm@gmail.com>2024-01-09 10:16:58 -0500
commit126aa9c893a5b5e3107ca9b6355d354753d45ed3 (patch)
tree75040427c791f5815597357c13e830824f9aa3fe /src
parentc1a3084312fa4fb7796b83640bfe9ad2b5044afa (diff)
Move channels to LiveTv project
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelDynamicMediaSourceProvider.cs43
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelImageProvider.cs64
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelManager.cs1257
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelPostScanTask.cs100
-rw-r--r--src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs88
-rw-r--r--src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj1
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>