< 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: 23
Uncovered lines: 396
Coverable lines: 419
Total lines: 778
Line coverage: 5.4%
Branch coverage
1%
Covered branches: 2
Total branches: 139
Branch coverage: 1.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 4/8/2026 - 12:11:47 AM Line coverage: 5.4% (10/183) Branch coverage: 0% (0/89) Total lines: 7614/19/2026 - 12:14:27 AM Line coverage: 5.4% (22/403) Branch coverage: 1.3% (2/143) Total lines: 7615/6/2026 - 12:15:23 AM Line coverage: 5.4% (23/424) Branch coverage: 1.3% (2/143) Total lines: 7866/9/2026 - 12:16:23 AM Line coverage: 5.3% (23/427) Branch coverage: 1.3% (2/147) Total lines: 7916/28/2026 - 12:15:35 AM Line coverage: 5.4% (23/419) Branch coverage: 1.4% (2/139) Total lines: 778 4/8/2026 - 12:11:47 AM Line coverage: 5.4% (10/183) Branch coverage: 0% (0/89) Total lines: 7614/19/2026 - 12:14:27 AM Line coverage: 5.4% (22/403) Branch coverage: 1.3% (2/143) Total lines: 7615/6/2026 - 12:15:23 AM Line coverage: 5.4% (23/424) Branch coverage: 1.3% (2/143) Total lines: 7866/9/2026 - 12:16:23 AM Line coverage: 5.3% (23/427) Branch coverage: 1.3% (2/147) Total lines: 7916/28/2026 - 12:15:35 AM Line coverage: 5.4% (23/419) Branch coverage: 1.4% (2/139) Total lines: 778

Coverage delta

Coverage delta 2 -2

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
GetGuideInfo()100%210%
RefreshGuide()20%411032.43%
GetGuideDays()0%620%
RefreshChannelsInternal()0%342180%
CleanDatabase(...)0%7280%
GetChannel()0%506220%
GetProgram(...)0%3192560%
UpdateImages(...)100%210%
UpdateImage(...)0%552230%
PreCacheImages()100%210%

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;
 9using Jellyfin.LiveTv.Configuration;
 10using MediaBrowser.Common.Configuration;
 11using MediaBrowser.Controller.Dto;
 12using MediaBrowser.Controller.Entities;
 13using MediaBrowser.Controller.Library;
 14using MediaBrowser.Controller.LiveTv;
 15using MediaBrowser.Controller.Persistence;
 16using MediaBrowser.Controller.Providers;
 17using MediaBrowser.Model.Entities;
 18using MediaBrowser.Model.IO;
 19using MediaBrowser.Model.LiveTv;
 20using Microsoft.Extensions.Logging;
 21
 22namespace Jellyfin.LiveTv.Guide;
 23
 24/// <inheritdoc />
 25public class GuideManager : IGuideManager
 26{
 27    private const int MaxGuideDays = 14;
 28    private const string EtagKey = "ProgramEtag";
 29    private const string ExternalServiceTag = "ExternalServiceId";
 30
 031    private static readonly ParallelOptions _cacheParallelOptions = new() { MaxDegreeOfParallelism = Math.Min(Environmen
 32
 33    private readonly ILogger<GuideManager> _logger;
 34    private readonly IConfigurationManager _config;
 35    private readonly IFileSystem _fileSystem;
 36    private readonly IItemRepository _itemRepo;
 37    private readonly ILibraryManager _libraryManager;
 38    private readonly ILiveTvManager _liveTvManager;
 39    private readonly ITunerHostManager _tunerHostManager;
 40    private readonly IRecordingsManager _recordingsManager;
 41    private readonly ISchedulesDirectService _schedulesDirectService;
 42    private readonly LiveTvDtoService _tvDtoService;
 43
 44    /// <summary>
 45    /// Amount of days images are pre-cached from external sources.
 46    /// </summary>
 47    public const int MaxCacheDays = 2;
 48
 49    /// <summary>
 50    /// Initializes a new instance of the <see cref="GuideManager"/> class.
 51    /// </summary>
 52    /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
 53    /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
 54    /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 55    /// <param name="itemRepo">The <see cref="IItemRepository"/>.</param>
 56    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
 57    /// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
 58    /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
 59    /// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
 60    /// <param name="schedulesDirectService">The <see cref="ISchedulesDirectService"/>.</param>
 61    /// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
 62    public GuideManager(
 63        ILogger<GuideManager> logger,
 64        IConfigurationManager config,
 65        IFileSystem fileSystem,
 66        IItemRepository itemRepo,
 67        ILibraryManager libraryManager,
 68        ILiveTvManager liveTvManager,
 69        ITunerHostManager tunerHostManager,
 70        IRecordingsManager recordingsManager,
 71        ISchedulesDirectService schedulesDirectService,
 72        LiveTvDtoService tvDtoService)
 73    {
 2274        _logger = logger;
 2275        _config = config;
 2276        _fileSystem = fileSystem;
 2277        _itemRepo = itemRepo;
 2278        _libraryManager = libraryManager;
 2279        _liveTvManager = liveTvManager;
 2280        _tunerHostManager = tunerHostManager;
 2281        _recordingsManager = recordingsManager;
 2282        _schedulesDirectService = schedulesDirectService;
 2283        _tvDtoService = tvDtoService;
 2284    }
 85
 86    /// <inheritdoc />
 87    public GuideInfo GetGuideInfo()
 88    {
 089        var startDate = DateTime.UtcNow;
 090        var endDate = startDate.AddDays(GetGuideDays());
 91
 092        return new GuideInfo
 093        {
 094            StartDate = startDate,
 095            EndDate = endDate
 096        };
 97    }
 98
 99    /// <inheritdoc />
 100    public async Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken)
 101    {
 1102        ArgumentNullException.ThrowIfNull(progress);
 103
 1104        await _recordingsManager.CreateRecordingFolders().ConfigureAwait(false);
 105
 1106        await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
 107
 1108        var numComplete = 0;
 1109        double progressPerService = _liveTvManager.Services.Count == 0
 1110            ? 0
 1111            : 1.0 / _liveTvManager.Services.Count;
 112
 1113        var newChannelIdList = new List<Guid>();
 1114        var newProgramIdList = new List<Guid>();
 115
 1116        var cleanDatabase = true;
 117
 3118        foreach (var service in _liveTvManager.Services)
 119        {
 1120            cancellationToken.ThrowIfCancellationRequested();
 121
 0122            _logger.LogDebug("Refreshing guide from {Name}", service.Name);
 123
 124            try
 125            {
 0126                var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService));
 127
 0128                var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(fal
 129
 0130                newChannelIdList.AddRange(idList.Item1);
 0131                newProgramIdList.AddRange(idList.Item2);
 0132            }
 0133            catch (OperationCanceledException)
 134            {
 0135                throw;
 136            }
 0137            catch (Exception ex)
 138            {
 0139                cleanDatabase = false;
 0140                _logger.LogError(ex, "Error refreshing channels for service");
 0141            }
 142
 0143            numComplete++;
 0144            double percent = numComplete;
 0145            percent /= _liveTvManager.Services.Count;
 146
 0147            progress.Report(100 * percent);
 148        }
 149
 0150        if (cleanDatabase)
 151        {
 0152            CleanDatabase(newChannelIdList.ToArray(), [BaseItemKind.LiveTvChannel], progress, cancellationToken);
 0153            CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken);
 154        }
 155
 0156        var coreService = _liveTvManager.Services.OfType<DefaultLiveTvService>().FirstOrDefault();
 0157        if (coreService is not null)
 158        {
 0159            await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
 0160            await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false);
 161        }
 162
 0163        progress.Report(100);
 0164    }
 165
 166    private double GetGuideDays()
 167    {
 0168        var config = _config.GetLiveTvConfiguration();
 169
 0170        return config.GuideDays.HasValue
 0171            ? Math.Clamp(config.GuideDays.Value, 1, MaxGuideDays)
 0172            : 7;
 173    }
 174
 175    private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> 
 176    {
 0177        progress.Report(10);
 178
 0179        var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
 0180            .Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
 0181            .ToList();
 182
 0183        var list = new List<LiveTvChannel>();
 184
 0185        var numComplete = 0;
 0186        var parentFolder = _liveTvManager.GetInternalLiveTvFolder(cancellationToken);
 187
 0188        foreach (var channelInfo in allChannelsList)
 189        {
 0190            cancellationToken.ThrowIfCancellationRequested();
 191
 192            try
 193            {
 0194                var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).Confi
 195
 0196                list.Add(item);
 0197            }
 0198            catch (OperationCanceledException)
 199            {
 0200                throw;
 201            }
 0202            catch (Exception ex)
 203            {
 0204                _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
 0205            }
 206
 0207            numComplete++;
 0208            double percent = numComplete;
 0209            percent /= allChannelsList.Count;
 210
 0211            progress.Report((5 * percent) + 10);
 0212        }
 213
 0214        progress.Report(15);
 215
 0216        numComplete = 0;
 0217        var programIds = new List<Guid>();
 0218        var channels = new List<Guid>();
 219
 0220        var guideDays = GetGuideDays();
 221
 0222        _logger.LogInformation("Refreshing guide with {Days} days of guide data", guideDays);
 223
 0224        var maxCacheDate = DateTime.UtcNow.AddDays(MaxCacheDays);
 0225        foreach (var currentChannel in list)
 226        {
 0227            cancellationToken.ThrowIfCancellationRequested();
 0228            channels.Add(currentChannel.Id);
 229
 230            try
 231            {
 0232                var start = DateTime.UtcNow.AddHours(-1);
 0233                var end = start.AddDays(guideDays);
 234
 0235                var isMovie = false;
 0236                var isSports = false;
 0237                var isNews = false;
 0238                var isKids = false;
 0239                var isSeries = false;
 240
 0241                var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellatio
 242
 0243                var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
 0244                {
 0245                    IncludeItemTypes = [BaseItemKind.LiveTvProgram],
 0246                    ChannelIds = [currentChannel.Id],
 0247                    DtoOptions = new DtoOptions(true)
 0248                }).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
 249
 0250                var newPrograms = new List<LiveTvProgram>();
 0251                var updatedPrograms = new List<LiveTvProgram>();
 252
 0253                foreach (var program in channelPrograms)
 254                {
 0255                    var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel);
 0256                    var id = programItem.Id;
 0257                    if (isNew)
 258                    {
 0259                        newPrograms.Add(programItem);
 260                    }
 0261                    else if (isUpdated)
 262                    {
 0263                        updatedPrograms.Add(programItem);
 264                    }
 265
 0266                    programIds.Add(programItem.Id);
 267
 0268                    isMovie |= program.IsMovie;
 0269                    isSeries |= program.IsSeries;
 0270                    isSports |= program.IsSports;
 0271                    isNews |= program.IsNews;
 0272                    isKids |= program.IsKids;
 273                }
 274
 0275                _logger.LogDebug(
 0276                    "Channel {Name} has {NewCount} new programs and {UpdatedCount} updated programs",
 0277                    currentChannel.Name,
 0278                    newPrograms.Count,
 0279                    updatedPrograms.Count);
 280
 0281                if (newPrograms.Count > 0)
 282                {
 0283                    _libraryManager.CreateItems(newPrograms, currentChannel, cancellationToken);
 284
 0285                    await PreCacheImages(newPrograms, maxCacheDate).ConfigureAwait(false);
 286                }
 287
 0288                if (updatedPrograms.Count > 0)
 289                {
 0290                    await _libraryManager.UpdateItemsAsync(
 0291                        updatedPrograms,
 0292                        currentChannel,
 0293                        ItemUpdateType.MetadataImport,
 0294                        cancellationToken).ConfigureAwait(false);
 295
 0296                    await PreCacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false);
 297                }
 298
 0299                currentChannel.IsMovie = isMovie;
 0300                currentChannel.IsNews = isNews;
 0301                currentChannel.IsSports = isSports;
 0302                currentChannel.IsSeries = isSeries;
 303
 0304                if (isKids)
 305                {
 0306                    currentChannel.AddTag("Kids");
 307                }
 308
 0309                await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).Configure
 0310                await currentChannel.RefreshMetadata(
 0311                    new MetadataRefreshOptions(new DirectoryService(_fileSystem))
 0312                    {
 0313                        ForceSave = true
 0314                    },
 0315                    cancellationToken).ConfigureAwait(false);
 0316            }
 0317            catch (OperationCanceledException)
 318            {
 0319                throw;
 320            }
 0321            catch (Exception ex)
 322            {
 0323                _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
 0324            }
 325
 0326            numComplete++;
 0327            double percent = numComplete / (double)allChannelsList.Count;
 328
 0329            progress.Report((85 * percent) + 15);
 0330        }
 331
 0332        progress.Report(100);
 0333        return new Tuple<List<Guid>, List<Guid>>(channels, programIds);
 0334    }
 335
 336    private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, Cancellation
 337    {
 0338        var list = _itemRepo.GetItemIdsList(new InternalItemsQuery
 0339        {
 0340            IncludeItemTypes = validTypes,
 0341            DtoOptions = new DtoOptions(false)
 0342        });
 343
 0344        var numComplete = 0;
 345
 0346        foreach (var itemId in list)
 347        {
 0348            cancellationToken.ThrowIfCancellationRequested();
 349
 0350            if (itemId.IsEmpty())
 351            {
 352                // Somehow some invalid data got into the db. It probably predates the boundary checking
 353                continue;
 354            }
 355
 0356            if (!currentIdList.Contains(itemId))
 357            {
 0358                var item = _libraryManager.GetItemById(itemId);
 359
 0360                if (item is not null)
 361                {
 0362                    _libraryManager.DeleteItem(
 0363                        item,
 0364                        new DeleteOptions
 0365                        {
 0366                            DeleteFileLocation = false,
 0367                            DeleteFromExternalProvider = false
 0368                        },
 0369                        false);
 370                }
 371            }
 372
 0373            numComplete++;
 0374            double percent = numComplete / (double)list.Count;
 375
 0376            progress.Report(100 * percent);
 377        }
 0378    }
 379
 380    private async Task<LiveTvChannel> GetChannel(
 381        ChannelInfo channelInfo,
 382        string serviceName,
 383        BaseItem parentFolder,
 384        CancellationToken cancellationToken)
 385    {
 0386        var parentFolderId = parentFolder.Id;
 0387        var isNew = false;
 0388        var forceUpdate = false;
 389
 0390        var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id);
 391
 0392        if (_libraryManager.GetItemById(id) is not LiveTvChannel item)
 393        {
 0394            item = new LiveTvChannel
 0395            {
 0396                Name = channelInfo.Name,
 0397                Id = id,
 0398                DateCreated = DateTime.UtcNow
 0399            };
 400
 0401            isNew = true;
 402        }
 403
 0404        if (channelInfo.Tags is not null)
 405        {
 0406            if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase))
 407            {
 0408                isNew = true;
 409            }
 410
 0411            item.Tags = channelInfo.Tags;
 412        }
 413
 0414        if (!item.ParentId.Equals(parentFolderId))
 415        {
 0416            isNew = true;
 417        }
 418
 0419        item.ParentId = parentFolderId;
 420
 0421        item.ChannelType = channelInfo.ChannelType;
 0422        item.ServiceName = serviceName;
 423
 0424        if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase))
 425        {
 0426            forceUpdate = true;
 427        }
 428
 0429        item.SetProviderId(ExternalServiceTag, serviceName);
 430
 0431        if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal))
 432        {
 0433            forceUpdate = true;
 434        }
 435
 0436        item.ExternalId = channelInfo.Id;
 437
 0438        if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal))
 439        {
 0440            forceUpdate = true;
 441        }
 442
 0443        item.Number = channelInfo.Number;
 444
 0445        if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal))
 446        {
 0447            forceUpdate = true;
 448        }
 449
 0450        item.Name = channelInfo.Name;
 451
 0452        if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl))
 453        {
 0454            forceUpdate = true;
 455        }
 456
 0457        if (isNew)
 458        {
 0459            _libraryManager.CreateItem(item, parentFolder);
 460        }
 0461        else if (forceUpdate)
 462        {
 0463            await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).
 464        }
 465
 0466        return item;
 0467    }
 468
 469    private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(
 470        ProgramInfo info,
 471        Dictionary<Guid, LiveTvProgram> allExistingPrograms,
 472        LiveTvChannel channel)
 473    {
 0474        var id = _tvDtoService.GetInternalProgramId(info.Id);
 475
 0476        var isNew = false;
 0477        var forceUpdate = false;
 478
 0479        if (!allExistingPrograms.TryGetValue(id, out var item))
 480        {
 0481            isNew = true;
 0482            item = new LiveTvProgram
 0483            {
 0484                Name = info.Name,
 0485                Id = id,
 0486                DateCreated = DateTime.UtcNow,
 0487                DateModified = DateTime.UtcNow
 0488            };
 489
 0490            item.TrySetProviderId(EtagKey, info.Etag);
 491        }
 492
 0493        if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
 494        {
 0495            item.ShowId = info.ShowId;
 0496            forceUpdate = true;
 497        }
 498
 0499        var channelId = channel.Id;
 0500        if (!item.ParentId.Equals(channelId))
 501        {
 0502            item.ParentId = channel.Id;
 0503            forceUpdate = true;
 504        }
 505
 0506        item.Audio = info.Audio;
 0507        item.ChannelId = channelId;
 0508        item.CommunityRating = info.CommunityRating;
 0509        item.EpisodeTitle = info.EpisodeTitle;
 0510        item.ExternalId = info.Id;
 511
 0512        var seriesId = info.SeriesId;
 0513        if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ord
 514        {
 0515            item.ExternalSeriesId = seriesId;
 0516            forceUpdate = true;
 517        }
 518
 0519        var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
 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();
 0567        item.Genres = info.Genres.ToArray();
 568
 0569        if (info.IsHD ?? false)
 570        {
 0571            item.Width = 1280;
 0572            item.Height = 720;
 573        }
 574
 0575        item.IsMovie = info.IsMovie;
 0576        item.IsRepeat = info.IsRepeat;
 0577        if (item.IsSeries != isSeries)
 578        {
 0579            item.IsSeries = isSeries;
 0580            forceUpdate = true;
 581        }
 582
 0583        item.Name = info.Name;
 0584        item.OfficialRating = info.OfficialRating;
 0585        item.Overview = info.Overview;
 0586        item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
 0587        foreach (var providerId in info.SeriesProviderIds)
 588        {
 0589            info.ProviderIds["Series" + providerId.Key] = providerId.Value;
 590        }
 591
 0592        item.ProviderIds = info.ProviderIds;
 0593        if (item.StartDate != info.StartDate)
 594        {
 0595            item.StartDate = info.StartDate;
 0596            forceUpdate = true;
 597        }
 598
 0599        if (item.EndDate != info.EndDate)
 600        {
 0601            item.EndDate = info.EndDate;
 0602            forceUpdate = true;
 603        }
 604
 0605        item.ProductionYear = info.ProductionYear;
 0606        if (!isSeries || info.IsRepeat)
 607        {
 0608            item.PremiereDate = info.OriginalAirDate;
 609        }
 610
 0611        item.IndexNumber = info.EpisodeNumber;
 0612        item.ParentIndexNumber = info.SeasonNumber;
 613
 0614        forceUpdate |= UpdateImages(item, info);
 615
 0616        if (isNew)
 617        {
 0618            item.OnMetadataChanged();
 619
 0620            return (item, true, false);
 621        }
 622
 0623        var isUpdated = forceUpdate;
 0624        var etag = info.Etag;
 0625        if (string.IsNullOrWhiteSpace(etag))
 626        {
 0627            isUpdated = true;
 628        }
 0629        else if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
 630        {
 0631            item.SetProviderId(EtagKey, etag);
 0632            isUpdated = true;
 633        }
 634
 0635        if (isUpdated)
 636        {
 0637            item.OnMetadataChanged();
 638
 0639            return (item, false, true);
 640        }
 641
 0642        return (item, false, false);
 643    }
 644
 645    private static bool UpdateImages(BaseItem item, ProgramInfo info)
 646    {
 0647        var updated = false;
 648
 649        // Primary
 0650        updated |= UpdateImage(ImageType.Primary, item, info);
 651
 652        // Thumbnail
 0653        updated |= UpdateImage(ImageType.Thumb, item, info);
 654
 655        // Logo
 0656        updated |= UpdateImage(ImageType.Logo, item, info);
 657
 658        // Backdrop
 0659        updated |= UpdateImage(ImageType.Backdrop, item, info);
 660
 0661        return updated;
 662    }
 663
 664    private static bool UpdateImage(ImageType imageType, BaseItem item, ProgramInfo info)
 665    {
 0666        var image = item.GetImages(imageType).FirstOrDefault();
 0667        var currentImagePath = image?.Path;
 0668        var newImagePath = imageType switch
 0669        {
 0670            ImageType.Primary => info.ImagePath,
 0671            _ => null
 0672        };
 0673        var newImageUrl = imageType switch
 0674        {
 0675            ImageType.Backdrop => info.BackdropImageUrl,
 0676            ImageType.Logo => info.LogoImageUrl,
 0677            ImageType.Primary => info.ImageUrl,
 0678            ImageType.Thumb => info.ThumbImageUrl,
 0679            _ => null
 0680        };
 681
 0682        var sameImage = (currentImagePath?.Equals(newImageUrl, StringComparison.OrdinalIgnoreCase) ?? false)
 0683                                || (currentImagePath?.Equals(newImagePath, StringComparison.OrdinalIgnoreCase) ?? false)
 0684        if (sameImage)
 685        {
 0686            return false;
 687        }
 688
 0689        if (!string.IsNullOrWhiteSpace(newImagePath))
 690        {
 0691            item.SetImage(
 0692                new ItemImageInfo
 0693                {
 0694                    Path = newImagePath,
 0695                    Type = imageType
 0696                },
 0697                0);
 698
 0699            return true;
 700        }
 701
 0702        if (!string.IsNullOrWhiteSpace(newImageUrl))
 703        {
 0704            item.SetImage(
 0705                new ItemImageInfo
 0706                {
 0707                    Path = newImageUrl,
 0708                    Type = imageType
 0709                },
 0710                0);
 711
 0712            return true;
 713        }
 714
 0715        item.RemoveImage(image);
 716
 0717        return false;
 718    }
 719
 720    private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
 721    {
 0722        var sdLimitActive = _schedulesDirectService.IsImageDailyLimitActive();
 723
 0724        await Parallel.ForEachAsync(
 0725            programs
 0726                .Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
 0727                .Where(p => !sdLimitActive || !p.ImageInfos.All(
 0728                    img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
 0729                .DistinctBy(p => p.Id),
 0730            _cacheParallelOptions,
 0731            async (program, cancellationToken) =>
 0732            {
 0733                // Re-check: limit may have been set by a parallel task since the LINQ filter ran.
 0734                if (_schedulesDirectService.IsImageDailyLimitActive()
 0735                    && program.ImageInfos.All(
 0736                        img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCas
 0737                {
 0738                    return;
 0739                }
 0740
 0741                for (var i = 0; i < program.ImageInfos.Length; i++)
 0742                {
 0743                    if (cancellationToken.IsCancellationRequested)
 0744                    {
 0745                        return;
 0746                    }
 0747
 0748                    var imageInfo = program.ImageInfos[i];
 0749                    if (imageInfo.IsLocalFile)
 0750                    {
 0751                        continue;
 0752                    }
 0753
 0754                    // Skip SD downloads once the daily limit has been hit.
 0755                    if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)
 0756                        && _schedulesDirectService.IsImageDailyLimitActive())
 0757                    {
 0758                        continue;
 0759                    }
 0760
 0761                    _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
 0762                    try
 0763                    {
 0764                        program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
 0765                                program,
 0766                                imageInfo,
 0767                                imageIndex: 0,
 0768                                removeOnFailure: false)
 0769                            .ConfigureAwait(false);
 0770                    }
 0771                    catch (Exception ex)
 0772                    {
 0773                        _logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
 0774                    }
 0775                }
 0776            }).ConfigureAwait(false);
 0777    }
 778}