< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Guide.GuideManager
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Guide/GuideManager.cs
Line coverage
5%
Covered lines: 10
Uncovered lines: 169
Coverable lines: 179
Total lines: 750
Line coverage: 5.5%
Branch coverage
0%
Covered branches: 0
Total branches: 94
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
GetGuideInfo()100%210%
GetGuideDays()0%620%
CleanDatabase(...)0%7280%
GetProgram(...)0%7140840%

File(s)

/srv/git/jellyfin/src/Jellyfin.LiveTv/Guide/GuideManager.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Threading;
 5using System.Threading.Tasks;
 6using Jellyfin.Data.Enums;
 7using Jellyfin.Extensions;
 8using Jellyfin.LiveTv.Configuration;
 9using MediaBrowser.Common.Configuration;
 10using MediaBrowser.Controller.Dto;
 11using MediaBrowser.Controller.Entities;
 12using MediaBrowser.Controller.Library;
 13using MediaBrowser.Controller.LiveTv;
 14using MediaBrowser.Controller.Persistence;
 15using MediaBrowser.Controller.Providers;
 16using MediaBrowser.Model.Entities;
 17using MediaBrowser.Model.IO;
 18using MediaBrowser.Model.LiveTv;
 19using Microsoft.Extensions.Logging;
 20
 21namespace Jellyfin.LiveTv.Guide;
 22
 23/// <inheritdoc />
 24public class GuideManager : IGuideManager
 25{
 26    private const int MaxGuideDays = 14;
 27    private const string EtagKey = "ProgramEtag";
 28    private const string ExternalServiceTag = "ExternalServiceId";
 29
 030    private static readonly ParallelOptions _cacheParallelOptions = new() { MaxDegreeOfParallelism = Math.Min(Environmen
 31
 32    private readonly ILogger<GuideManager> _logger;
 33    private readonly IConfigurationManager _config;
 34    private readonly IFileSystem _fileSystem;
 35    private readonly IItemRepository _itemRepo;
 36    private readonly ILibraryManager _libraryManager;
 37    private readonly ILiveTvManager _liveTvManager;
 38    private readonly ITunerHostManager _tunerHostManager;
 39    private readonly IRecordingsManager _recordingsManager;
 40    private readonly LiveTvDtoService _tvDtoService;
 41
 42    /// <summary>
 43    /// Initializes a new instance of the <see cref="GuideManager"/> class.
 44    /// </summary>
 45    /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
 46    /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
 47    /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 48    /// <param name="itemRepo">The <see cref="IItemRepository"/>.</param>
 49    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
 50    /// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
 51    /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
 52    /// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
 53    /// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
 54    public GuideManager(
 55        ILogger<GuideManager> logger,
 56        IConfigurationManager config,
 57        IFileSystem fileSystem,
 58        IItemRepository itemRepo,
 59        ILibraryManager libraryManager,
 60        ILiveTvManager liveTvManager,
 61        ITunerHostManager tunerHostManager,
 62        IRecordingsManager recordingsManager,
 63        LiveTvDtoService tvDtoService)
 64    {
 2265        _logger = logger;
 2266        _config = config;
 2267        _fileSystem = fileSystem;
 2268        _itemRepo = itemRepo;
 2269        _libraryManager = libraryManager;
 2270        _liveTvManager = liveTvManager;
 2271        _tunerHostManager = tunerHostManager;
 2272        _recordingsManager = recordingsManager;
 2273        _tvDtoService = tvDtoService;
 2274    }
 75
 76    /// <inheritdoc />
 77    public GuideInfo GetGuideInfo()
 78    {
 079        var startDate = DateTime.UtcNow;
 080        var endDate = startDate.AddDays(GetGuideDays());
 81
 082        return new GuideInfo
 083        {
 084            StartDate = startDate,
 085            EndDate = endDate
 086        };
 87    }
 88
 89    /// <inheritdoc />
 90    public async Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken)
 91    {
 92        ArgumentNullException.ThrowIfNull(progress);
 93
 94        await _recordingsManager.CreateRecordingFolders().ConfigureAwait(false);
 95
 96        await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
 97
 98        var numComplete = 0;
 99        double progressPerService = _liveTvManager.Services.Count == 0
 100            ? 0
 101            : 1.0 / _liveTvManager.Services.Count;
 102
 103        var newChannelIdList = new List<Guid>();
 104        var newProgramIdList = new List<Guid>();
 105
 106        var cleanDatabase = true;
 107
 108        foreach (var service in _liveTvManager.Services)
 109        {
 110            cancellationToken.ThrowIfCancellationRequested();
 111
 112            _logger.LogDebug("Refreshing guide from {Name}", service.Name);
 113
 114            try
 115            {
 116                var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService));
 117
 118                var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(fal
 119
 120                newChannelIdList.AddRange(idList.Item1);
 121                newProgramIdList.AddRange(idList.Item2);
 122            }
 123            catch (OperationCanceledException)
 124            {
 125                throw;
 126            }
 127            catch (Exception ex)
 128            {
 129                cleanDatabase = false;
 130                _logger.LogError(ex, "Error refreshing channels for service");
 131            }
 132
 133            numComplete++;
 134            double percent = numComplete;
 135            percent /= _liveTvManager.Services.Count;
 136
 137            progress.Report(100 * percent);
 138        }
 139
 140        if (cleanDatabase)
 141        {
 142            CleanDatabase(newChannelIdList.ToArray(), [BaseItemKind.LiveTvChannel], progress, cancellationToken);
 143            CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken);
 144        }
 145
 146        var coreService = _liveTvManager.Services.OfType<DefaultLiveTvService>().FirstOrDefault();
 147        if (coreService is not null)
 148        {
 149            await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
 150            await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false);
 151        }
 152
 153        progress.Report(100);
 154    }
 155
 156    private double GetGuideDays()
 157    {
 0158        var config = _config.GetLiveTvConfiguration();
 159
 0160        return config.GuideDays.HasValue
 0161            ? Math.Clamp(config.GuideDays.Value, 1, MaxGuideDays)
 0162            : 7;
 163    }
 164
 165    private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> 
 166    {
 167        progress.Report(10);
 168
 169        var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
 170            .Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
 171            .ToList();
 172
 173        var list = new List<LiveTvChannel>();
 174
 175        var numComplete = 0;
 176        var parentFolder = _liveTvManager.GetInternalLiveTvFolder(cancellationToken);
 177
 178        foreach (var channelInfo in allChannelsList)
 179        {
 180            cancellationToken.ThrowIfCancellationRequested();
 181
 182            try
 183            {
 184                var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).Confi
 185
 186                list.Add(item);
 187            }
 188            catch (OperationCanceledException)
 189            {
 190                throw;
 191            }
 192            catch (Exception ex)
 193            {
 194                _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
 195            }
 196
 197            numComplete++;
 198            double percent = numComplete;
 199            percent /= allChannelsList.Count;
 200
 201            progress.Report((5 * percent) + 10);
 202        }
 203
 204        progress.Report(15);
 205
 206        numComplete = 0;
 207        var programs = new List<Guid>();
 208        var channels = new List<Guid>();
 209
 210        var guideDays = GetGuideDays();
 211
 212        _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays);
 213
 214        var maxCacheDate = DateTime.UtcNow.AddDays(2);
 215        foreach (var currentChannel in list)
 216        {
 217            cancellationToken.ThrowIfCancellationRequested();
 218            channels.Add(currentChannel.Id);
 219
 220            try
 221            {
 222                var start = DateTime.UtcNow.AddHours(-1);
 223                var end = start.AddDays(guideDays);
 224
 225                var isMovie = false;
 226                var isSports = false;
 227                var isNews = false;
 228                var isKids = false;
 229                var isSeries = false;
 230
 231                var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellatio
 232
 233                var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
 234                {
 235                    IncludeItemTypes = [BaseItemKind.LiveTvProgram],
 236                    ChannelIds = [currentChannel.Id],
 237                    DtoOptions = new DtoOptions(true)
 238                }).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
 239
 240                var newPrograms = new List<LiveTvProgram>();
 241                var updatedPrograms = new List<BaseItem>();
 242
 243                foreach (var program in channelPrograms)
 244                {
 245                    var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel);
 246                    if (isNew)
 247                    {
 248                        newPrograms.Add(programItem);
 249                    }
 250                    else if (isUpdated)
 251                    {
 252                        updatedPrograms.Add(programItem);
 253                    }
 254
 255                    programs.Add(programItem.Id);
 256
 257                    isMovie |= program.IsMovie;
 258                    isSeries |= program.IsSeries;
 259                    isSports |= program.IsSports;
 260                    isNews |= program.IsNews;
 261                    isKids |= program.IsKids;
 262                }
 263
 264                _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPr
 265
 266                if (newPrograms.Count > 0)
 267                {
 268                    _libraryManager.CreateItems(newPrograms, null, cancellationToken);
 269                    await PrecacheImages(newPrograms, maxCacheDate).ConfigureAwait(false);
 270                }
 271
 272                if (updatedPrograms.Count > 0)
 273                {
 274                    await _libraryManager.UpdateItemsAsync(
 275                        updatedPrograms,
 276                        currentChannel,
 277                        ItemUpdateType.MetadataImport,
 278                        cancellationToken).ConfigureAwait(false);
 279                    await PrecacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false);
 280                }
 281
 282                currentChannel.IsMovie = isMovie;
 283                currentChannel.IsNews = isNews;
 284                currentChannel.IsSports = isSports;
 285                currentChannel.IsSeries = isSeries;
 286
 287                if (isKids)
 288                {
 289                    currentChannel.AddTag("Kids");
 290                }
 291
 292                await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).Configure
 293                await currentChannel.RefreshMetadata(
 294                    new MetadataRefreshOptions(new DirectoryService(_fileSystem))
 295                    {
 296                        ForceSave = true
 297                    },
 298                    cancellationToken).ConfigureAwait(false);
 299            }
 300            catch (OperationCanceledException)
 301            {
 302                throw;
 303            }
 304            catch (Exception ex)
 305            {
 306                _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
 307            }
 308
 309            numComplete++;
 310            double percent = numComplete / (double)allChannelsList.Count;
 311
 312            progress.Report((85 * percent) + 15);
 313        }
 314
 315        progress.Report(100);
 316        return new Tuple<List<Guid>, List<Guid>>(channels, programs);
 317    }
 318
 319    private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, Cancellation
 320    {
 0321        var list = _itemRepo.GetItemIdsList(new InternalItemsQuery
 0322        {
 0323            IncludeItemTypes = validTypes,
 0324            DtoOptions = new DtoOptions(false)
 0325        });
 326
 0327        var numComplete = 0;
 328
 0329        foreach (var itemId in list)
 330        {
 0331            cancellationToken.ThrowIfCancellationRequested();
 332
 0333            if (itemId.IsEmpty())
 334            {
 335                // Somehow some invalid data got into the db. It probably predates the boundary checking
 336                continue;
 337            }
 338
 0339            if (!currentIdList.Contains(itemId))
 340            {
 0341                var item = _libraryManager.GetItemById(itemId);
 342
 0343                if (item is not null)
 344                {
 0345                    _libraryManager.DeleteItem(
 0346                        item,
 0347                        new DeleteOptions
 0348                        {
 0349                            DeleteFileLocation = false,
 0350                            DeleteFromExternalProvider = false
 0351                        },
 0352                        false);
 353                }
 354            }
 355
 0356            numComplete++;
 0357            double percent = numComplete / (double)list.Count;
 358
 0359            progress.Report(100 * percent);
 360        }
 0361    }
 362
 363    private async Task<LiveTvChannel> GetChannel(
 364        ChannelInfo channelInfo,
 365        string serviceName,
 366        BaseItem parentFolder,
 367        CancellationToken cancellationToken)
 368    {
 369        var parentFolderId = parentFolder.Id;
 370        var isNew = false;
 371        var forceUpdate = false;
 372
 373        var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id);
 374
 375        if (_libraryManager.GetItemById(id) is not LiveTvChannel item)
 376        {
 377            item = new LiveTvChannel
 378            {
 379                Name = channelInfo.Name,
 380                Id = id,
 381                DateCreated = DateTime.UtcNow
 382            };
 383
 384            isNew = true;
 385        }
 386
 387        if (channelInfo.Tags is not null)
 388        {
 389            if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase))
 390            {
 391                isNew = true;
 392            }
 393
 394            item.Tags = channelInfo.Tags;
 395        }
 396
 397        if (!item.ParentId.Equals(parentFolderId))
 398        {
 399            isNew = true;
 400        }
 401
 402        item.ParentId = parentFolderId;
 403
 404        item.ChannelType = channelInfo.ChannelType;
 405        item.ServiceName = serviceName;
 406
 407        if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase))
 408        {
 409            forceUpdate = true;
 410        }
 411
 412        item.SetProviderId(ExternalServiceTag, serviceName);
 413
 414        if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal))
 415        {
 416            forceUpdate = true;
 417        }
 418
 419        item.ExternalId = channelInfo.Id;
 420
 421        if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal))
 422        {
 423            forceUpdate = true;
 424        }
 425
 426        item.Number = channelInfo.Number;
 427
 428        if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal))
 429        {
 430            forceUpdate = true;
 431        }
 432
 433        item.Name = channelInfo.Name;
 434
 435        if (!item.HasImage(ImageType.Primary))
 436        {
 437            if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
 438            {
 439                item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
 440                forceUpdate = true;
 441            }
 442            else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
 443            {
 444                item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
 445                forceUpdate = true;
 446            }
 447        }
 448
 449        if (isNew)
 450        {
 451            _libraryManager.CreateItem(item, parentFolder);
 452        }
 453        else if (forceUpdate)
 454        {
 455            await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).
 456        }
 457
 458        return item;
 459    }
 460
 461    private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(
 462        ProgramInfo info,
 463        Dictionary<Guid, LiveTvProgram> allExistingPrograms,
 464        LiveTvChannel channel)
 465    {
 0466        var id = _tvDtoService.GetInternalProgramId(info.Id);
 467
 0468        var isNew = false;
 0469        var forceUpdate = false;
 470
 0471        if (!allExistingPrograms.TryGetValue(id, out var item))
 472        {
 0473            isNew = true;
 0474            item = new LiveTvProgram
 0475            {
 0476                Name = info.Name,
 0477                Id = id,
 0478                DateCreated = DateTime.UtcNow,
 0479                DateModified = DateTime.UtcNow
 0480            };
 481
 0482            item.TrySetProviderId(EtagKey, info.Etag);
 483        }
 484
 0485        if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
 486        {
 0487            item.ShowId = info.ShowId;
 0488            forceUpdate = true;
 489        }
 490
 0491        var seriesId = info.SeriesId;
 492
 0493        if (!item.ParentId.Equals(channel.Id))
 494        {
 0495            forceUpdate = true;
 496        }
 497
 0498        item.ParentId = channel.Id;
 499
 0500        item.Audio = info.Audio;
 0501        item.ChannelId = channel.Id;
 0502        item.CommunityRating ??= info.CommunityRating;
 0503        if ((item.CommunityRating ?? 0).Equals(0))
 504        {
 0505            item.CommunityRating = null;
 506        }
 507
 0508        item.EpisodeTitle = info.EpisodeTitle;
 0509        item.ExternalId = info.Id;
 510
 0511        if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ord
 512        {
 0513            forceUpdate = true;
 514        }
 515
 0516        item.ExternalSeriesId = seriesId;
 517
 0518        var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
 519
 0520        if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle))
 521        {
 0522            item.SeriesName = info.Name;
 523        }
 524
 0525        var tags = new List<string>();
 0526        if (info.IsLive)
 527        {
 0528            tags.Add("Live");
 529        }
 530
 0531        if (info.IsPremiere)
 532        {
 0533            tags.Add("Premiere");
 534        }
 535
 0536        if (info.IsNews)
 537        {
 0538            tags.Add("News");
 539        }
 540
 0541        if (info.IsSports)
 542        {
 0543            tags.Add("Sports");
 544        }
 545
 0546        if (info.IsKids)
 547        {
 0548            tags.Add("Kids");
 549        }
 550
 0551        if (info.IsRepeat)
 552        {
 0553            tags.Add("Repeat");
 554        }
 555
 0556        if (info.IsMovie)
 557        {
 0558            tags.Add("Movie");
 559        }
 560
 0561        if (isSeries)
 562        {
 0563            tags.Add("Series");
 564        }
 565
 0566        item.Tags = tags.ToArray();
 567
 0568        item.Genres = info.Genres.ToArray();
 569
 0570        if (info.IsHD ?? false)
 571        {
 0572            item.Width = 1280;
 0573            item.Height = 720;
 574        }
 575
 0576        item.IsMovie = info.IsMovie;
 0577        item.IsRepeat = info.IsRepeat;
 578
 0579        if (item.IsSeries != isSeries)
 580        {
 0581            forceUpdate = true;
 582        }
 583
 0584        item.IsSeries = isSeries;
 585
 0586        item.Name = info.Name;
 0587        item.OfficialRating ??= info.OfficialRating;
 0588        item.Overview ??= info.Overview;
 0589        item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
 0590        item.ProviderIds = info.ProviderIds;
 591
 0592        foreach (var providerId in info.SeriesProviderIds)
 593        {
 0594            info.ProviderIds["Series" + providerId.Key] = providerId.Value;
 595        }
 596
 0597        if (item.StartDate != info.StartDate)
 598        {
 0599            forceUpdate = true;
 600        }
 601
 0602        item.StartDate = info.StartDate;
 603
 0604        if (item.EndDate != info.EndDate)
 605        {
 0606            forceUpdate = true;
 607        }
 608
 0609        item.EndDate = info.EndDate;
 610
 0611        item.ProductionYear = info.ProductionYear;
 612
 0613        if (!isSeries || info.IsRepeat)
 614        {
 0615            item.PremiereDate = info.OriginalAirDate;
 616        }
 617
 0618        item.IndexNumber = info.EpisodeNumber;
 0619        item.ParentIndexNumber = info.SeasonNumber;
 620
 0621        if (!item.HasImage(ImageType.Primary))
 622        {
 0623            if (!string.IsNullOrWhiteSpace(info.ImagePath))
 624            {
 0625                item.SetImage(
 0626                    new ItemImageInfo
 0627                    {
 0628                        Path = info.ImagePath,
 0629                        Type = ImageType.Primary
 0630                    },
 0631                    0);
 632            }
 0633            else if (!string.IsNullOrWhiteSpace(info.ImageUrl))
 634            {
 0635                item.SetImage(
 0636                    new ItemImageInfo
 0637                    {
 0638                        Path = info.ImageUrl,
 0639                        Type = ImageType.Primary
 0640                    },
 0641                    0);
 642            }
 643        }
 644
 0645        if (!item.HasImage(ImageType.Thumb))
 646        {
 0647            if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl))
 648            {
 0649                item.SetImage(
 0650                    new ItemImageInfo
 0651                    {
 0652                        Path = info.ThumbImageUrl,
 0653                        Type = ImageType.Thumb
 0654                    },
 0655                    0);
 656            }
 657        }
 658
 0659        if (!item.HasImage(ImageType.Logo))
 660        {
 0661            if (!string.IsNullOrWhiteSpace(info.LogoImageUrl))
 662            {
 0663                item.SetImage(
 0664                    new ItemImageInfo
 0665                    {
 0666                        Path = info.LogoImageUrl,
 0667                        Type = ImageType.Logo
 0668                    },
 0669                    0);
 670            }
 671        }
 672
 0673        if (!item.HasImage(ImageType.Backdrop))
 674        {
 0675            if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl))
 676            {
 0677                item.SetImage(
 0678                    new ItemImageInfo
 0679                    {
 0680                        Path = info.BackdropImageUrl,
 0681                        Type = ImageType.Backdrop
 0682                    },
 0683                    0);
 684            }
 685        }
 686
 0687        var isUpdated = false;
 0688        if (isNew)
 689        {
 690        }
 0691        else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag))
 692        {
 0693            isUpdated = true;
 694        }
 695        else
 696        {
 0697            var etag = info.Etag;
 698
 0699            if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
 700            {
 0701                item.SetProviderId(EtagKey, etag);
 0702                isUpdated = true;
 703            }
 704        }
 705
 0706        if (isNew || isUpdated)
 707        {
 0708            item.OnMetadataChanged();
 709        }
 710
 0711        return (item, isNew, isUpdated);
 712    }
 713
 714    private async Task PrecacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
 715    {
 716        await Parallel.ForEachAsync(
 717            programs
 718                .Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
 719                .DistinctBy(p => p.Id),
 720            _cacheParallelOptions,
 721            async (program, cancellationToken) =>
 722            {
 723                for (var i = 0; i < program.ImageInfos.Length; i++)
 724                {
 725                    if (cancellationToken.IsCancellationRequested)
 726                    {
 727                        return;
 728                    }
 729
 730                    var imageInfo = program.ImageInfos[i];
 731                    if (!imageInfo.IsLocalFile)
 732                    {
 733                        try
 734                        {
 735                            program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
 736                                    program,
 737                                    imageInfo,
 738                                    imageIndex: 0,
 739                                    removeOnFailure: false)
 740                                .ConfigureAwait(false);
 741                        }
 742                        catch (Exception ex)
 743                        {
 744                            _logger.LogWarning(ex, "Unable to precache {Url}", imageInfo.Path);
 745                        }
 746                    }
 747                }
 748            }).ConfigureAwait(false);
 749    }
 750}