< 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: 401
Coverable lines: 424
Total lines: 786
Line coverage: 5.4%
Branch coverage
1%
Covered branches: 2
Total branches: 143
Branch coverage: 1.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 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: 786 1/23/2026 - 12:11:06 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: 786

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%702260%
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.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 ISchedulesDirectService _schedulesDirectService;
 41    private readonly LiveTvDtoService _tvDtoService;
 42
 43    /// <summary>
 44    /// Amount of days images are pre-cached from external sources.
 45    /// </summary>
 46    public const int MaxCacheDays = 2;
 47
 48    /// <summary>
 49    /// Initializes a new instance of the <see cref="GuideManager"/> class.
 50    /// </summary>
 51    /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
 52    /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
 53    /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 54    /// <param name="itemRepo">The <see cref="IItemRepository"/>.</param>
 55    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
 56    /// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
 57    /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
 58    /// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
 59    /// <param name="schedulesDirectService">The <see cref="ISchedulesDirectService"/>.</param>
 60    /// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
 61    public GuideManager(
 62        ILogger<GuideManager> logger,
 63        IConfigurationManager config,
 64        IFileSystem fileSystem,
 65        IItemRepository itemRepo,
 66        ILibraryManager libraryManager,
 67        ILiveTvManager liveTvManager,
 68        ITunerHostManager tunerHostManager,
 69        IRecordingsManager recordingsManager,
 70        ISchedulesDirectService schedulesDirectService,
 71        LiveTvDtoService tvDtoService)
 72    {
 2173        _logger = logger;
 2174        _config = config;
 2175        _fileSystem = fileSystem;
 2176        _itemRepo = itemRepo;
 2177        _libraryManager = libraryManager;
 2178        _liveTvManager = liveTvManager;
 2179        _tunerHostManager = tunerHostManager;
 2180        _recordingsManager = recordingsManager;
 2181        _schedulesDirectService = schedulesDirectService;
 2182        _tvDtoService = tvDtoService;
 2183    }
 84
 85    /// <inheritdoc />
 86    public GuideInfo GetGuideInfo()
 87    {
 088        var startDate = DateTime.UtcNow;
 089        var endDate = startDate.AddDays(GetGuideDays());
 90
 091        return new GuideInfo
 092        {
 093            StartDate = startDate,
 094            EndDate = endDate
 095        };
 96    }
 97
 98    /// <inheritdoc />
 99    public async Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken)
 100    {
 1101        ArgumentNullException.ThrowIfNull(progress);
 102
 1103        await _recordingsManager.CreateRecordingFolders().ConfigureAwait(false);
 104
 1105        await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
 106
 1107        var numComplete = 0;
 1108        double progressPerService = _liveTvManager.Services.Count == 0
 1109            ? 0
 1110            : 1.0 / _liveTvManager.Services.Count;
 111
 1112        var newChannelIdList = new List<Guid>();
 1113        var newProgramIdList = new List<Guid>();
 114
 1115        var cleanDatabase = true;
 116
 3117        foreach (var service in _liveTvManager.Services)
 118        {
 1119            cancellationToken.ThrowIfCancellationRequested();
 120
 0121            _logger.LogDebug("Refreshing guide from {Name}", service.Name);
 122
 123            try
 124            {
 0125                var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService));
 126
 0127                var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(fal
 128
 0129                newChannelIdList.AddRange(idList.Item1);
 0130                newProgramIdList.AddRange(idList.Item2);
 0131            }
 0132            catch (OperationCanceledException)
 133            {
 0134                throw;
 135            }
 0136            catch (Exception ex)
 137            {
 0138                cleanDatabase = false;
 0139                _logger.LogError(ex, "Error refreshing channels for service");
 0140            }
 141
 0142            numComplete++;
 0143            double percent = numComplete;
 0144            percent /= _liveTvManager.Services.Count;
 145
 0146            progress.Report(100 * percent);
 147        }
 148
 0149        if (cleanDatabase)
 150        {
 0151            CleanDatabase(newChannelIdList.ToArray(), [BaseItemKind.LiveTvChannel], progress, cancellationToken);
 0152            CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken);
 153        }
 154
 0155        var coreService = _liveTvManager.Services.OfType<DefaultLiveTvService>().FirstOrDefault();
 0156        if (coreService is not null)
 157        {
 0158            await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
 0159            await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false);
 160        }
 161
 0162        progress.Report(100);
 0163    }
 164
 165    private double GetGuideDays()
 166    {
 0167        var config = _config.GetLiveTvConfiguration();
 168
 0169        return config.GuideDays.HasValue
 0170            ? Math.Clamp(config.GuideDays.Value, 1, MaxGuideDays)
 0171            : 7;
 172    }
 173
 174    private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> 
 175    {
 0176        progress.Report(10);
 177
 0178        var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
 0179            .Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
 0180            .ToList();
 181
 0182        var list = new List<LiveTvChannel>();
 183
 0184        var numComplete = 0;
 0185        var parentFolder = _liveTvManager.GetInternalLiveTvFolder(cancellationToken);
 186
 0187        foreach (var channelInfo in allChannelsList)
 188        {
 0189            cancellationToken.ThrowIfCancellationRequested();
 190
 191            try
 192            {
 0193                var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).Confi
 194
 0195                list.Add(item);
 0196            }
 0197            catch (OperationCanceledException)
 198            {
 0199                throw;
 200            }
 0201            catch (Exception ex)
 202            {
 0203                _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
 0204            }
 205
 0206            numComplete++;
 0207            double percent = numComplete;
 0208            percent /= allChannelsList.Count;
 209
 0210            progress.Report((5 * percent) + 10);
 0211        }
 212
 0213        progress.Report(15);
 214
 0215        numComplete = 0;
 0216        var programIds = new List<Guid>();
 0217        var channels = new List<Guid>();
 218
 0219        var guideDays = GetGuideDays();
 220
 0221        _logger.LogInformation("Refreshing guide with {Days} days of guide data", guideDays);
 222
 0223        var maxCacheDate = DateTime.UtcNow.AddDays(MaxCacheDays);
 0224        foreach (var currentChannel in list)
 225        {
 0226            cancellationToken.ThrowIfCancellationRequested();
 0227            channels.Add(currentChannel.Id);
 228
 229            try
 230            {
 0231                var start = DateTime.UtcNow.AddHours(-1);
 0232                var end = start.AddDays(guideDays);
 233
 0234                var isMovie = false;
 0235                var isSports = false;
 0236                var isNews = false;
 0237                var isKids = false;
 0238                var isSeries = false;
 239
 0240                var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellatio
 241
 0242                var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
 0243                {
 0244                    IncludeItemTypes = [BaseItemKind.LiveTvProgram],
 0245                    ChannelIds = [currentChannel.Id],
 0246                    DtoOptions = new DtoOptions(true)
 0247                }).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
 248
 0249                var newPrograms = new List<LiveTvProgram>();
 0250                var updatedPrograms = new List<LiveTvProgram>();
 251
 0252                foreach (var program in channelPrograms)
 253                {
 0254                    var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel);
 0255                    var id = programItem.Id;
 0256                    if (isNew)
 257                    {
 0258                        newPrograms.Add(programItem);
 259                    }
 0260                    else if (isUpdated)
 261                    {
 0262                        updatedPrograms.Add(programItem);
 263                    }
 264
 0265                    programIds.Add(programItem.Id);
 266
 0267                    isMovie |= program.IsMovie;
 0268                    isSeries |= program.IsSeries;
 0269                    isSports |= program.IsSports;
 0270                    isNews |= program.IsNews;
 0271                    isKids |= program.IsKids;
 272                }
 273
 0274                _logger.LogDebug(
 0275                    "Channel {Name} has {NewCount} new programs and {UpdatedCount} updated programs",
 0276                    currentChannel.Name,
 0277                    newPrograms.Count,
 0278                    updatedPrograms.Count);
 279
 0280                if (newPrograms.Count > 0)
 281                {
 0282                    _libraryManager.CreateItems(newPrograms, currentChannel, cancellationToken);
 283
 0284                    await PreCacheImages(newPrograms, maxCacheDate).ConfigureAwait(false);
 285                }
 286
 0287                if (updatedPrograms.Count > 0)
 288                {
 0289                    await _libraryManager.UpdateItemsAsync(
 0290                        updatedPrograms,
 0291                        currentChannel,
 0292                        ItemUpdateType.MetadataImport,
 0293                        cancellationToken).ConfigureAwait(false);
 294
 0295                    await PreCacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false);
 296                }
 297
 0298                currentChannel.IsMovie = isMovie;
 0299                currentChannel.IsNews = isNews;
 0300                currentChannel.IsSports = isSports;
 0301                currentChannel.IsSeries = isSeries;
 302
 0303                if (isKids)
 304                {
 0305                    currentChannel.AddTag("Kids");
 306                }
 307
 0308                await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).Configure
 0309                await currentChannel.RefreshMetadata(
 0310                    new MetadataRefreshOptions(new DirectoryService(_fileSystem))
 0311                    {
 0312                        ForceSave = true
 0313                    },
 0314                    cancellationToken).ConfigureAwait(false);
 0315            }
 0316            catch (OperationCanceledException)
 317            {
 0318                throw;
 319            }
 0320            catch (Exception ex)
 321            {
 0322                _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
 0323            }
 324
 0325            numComplete++;
 0326            double percent = numComplete / (double)allChannelsList.Count;
 327
 0328            progress.Report((85 * percent) + 15);
 0329        }
 330
 0331        progress.Report(100);
 0332        return new Tuple<List<Guid>, List<Guid>>(channels, programIds);
 0333    }
 334
 335    private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, Cancellation
 336    {
 0337        var list = _itemRepo.GetItemIdsList(new InternalItemsQuery
 0338        {
 0339            IncludeItemTypes = validTypes,
 0340            DtoOptions = new DtoOptions(false)
 0341        });
 342
 0343        var numComplete = 0;
 344
 0345        foreach (var itemId in list)
 346        {
 0347            cancellationToken.ThrowIfCancellationRequested();
 348
 0349            if (itemId.IsEmpty())
 350            {
 351                // Somehow some invalid data got into the db. It probably predates the boundary checking
 352                continue;
 353            }
 354
 0355            if (!currentIdList.Contains(itemId))
 356            {
 0357                var item = _libraryManager.GetItemById(itemId);
 358
 0359                if (item is not null)
 360                {
 0361                    _libraryManager.DeleteItem(
 0362                        item,
 0363                        new DeleteOptions
 0364                        {
 0365                            DeleteFileLocation = false,
 0366                            DeleteFromExternalProvider = false
 0367                        },
 0368                        false);
 369                }
 370            }
 371
 0372            numComplete++;
 0373            double percent = numComplete / (double)list.Count;
 374
 0375            progress.Report(100 * percent);
 376        }
 0377    }
 378
 379    private async Task<LiveTvChannel> GetChannel(
 380        ChannelInfo channelInfo,
 381        string serviceName,
 382        BaseItem parentFolder,
 383        CancellationToken cancellationToken)
 384    {
 0385        var parentFolderId = parentFolder.Id;
 0386        var isNew = false;
 0387        var forceUpdate = false;
 388
 0389        var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id);
 390
 0391        if (_libraryManager.GetItemById(id) is not LiveTvChannel item)
 392        {
 0393            item = new LiveTvChannel
 0394            {
 0395                Name = channelInfo.Name,
 0396                Id = id,
 0397                DateCreated = DateTime.UtcNow
 0398            };
 399
 0400            isNew = true;
 401        }
 402
 0403        if (channelInfo.Tags is not null)
 404        {
 0405            if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase))
 406            {
 0407                isNew = true;
 408            }
 409
 0410            item.Tags = channelInfo.Tags;
 411        }
 412
 0413        if (!item.ParentId.Equals(parentFolderId))
 414        {
 0415            isNew = true;
 416        }
 417
 0418        item.ParentId = parentFolderId;
 419
 0420        item.ChannelType = channelInfo.ChannelType;
 0421        item.ServiceName = serviceName;
 422
 0423        if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase))
 424        {
 0425            forceUpdate = true;
 426        }
 427
 0428        item.SetProviderId(ExternalServiceTag, serviceName);
 429
 0430        if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal))
 431        {
 0432            forceUpdate = true;
 433        }
 434
 0435        item.ExternalId = channelInfo.Id;
 436
 0437        if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal))
 438        {
 0439            forceUpdate = true;
 440        }
 441
 0442        item.Number = channelInfo.Number;
 443
 0444        if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal))
 445        {
 0446            forceUpdate = true;
 447        }
 448
 0449        item.Name = channelInfo.Name;
 450
 0451        if (!item.HasImage(ImageType.Primary))
 452        {
 0453            if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
 454            {
 0455                item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
 0456                forceUpdate = true;
 457            }
 0458            else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
 459            {
 0460                item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
 0461                forceUpdate = true;
 462            }
 463        }
 464
 0465        if (isNew)
 466        {
 0467            _libraryManager.CreateItem(item, parentFolder);
 468        }
 0469        else if (forceUpdate)
 470        {
 0471            await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).
 472        }
 473
 0474        return item;
 0475    }
 476
 477    private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(
 478        ProgramInfo info,
 479        Dictionary<Guid, LiveTvProgram> allExistingPrograms,
 480        LiveTvChannel channel)
 481    {
 0482        var id = _tvDtoService.GetInternalProgramId(info.Id);
 483
 0484        var isNew = false;
 0485        var forceUpdate = false;
 486
 0487        if (!allExistingPrograms.TryGetValue(id, out var item))
 488        {
 0489            isNew = true;
 0490            item = new LiveTvProgram
 0491            {
 0492                Name = info.Name,
 0493                Id = id,
 0494                DateCreated = DateTime.UtcNow,
 0495                DateModified = DateTime.UtcNow
 0496            };
 497
 0498            item.TrySetProviderId(EtagKey, info.Etag);
 499        }
 500
 0501        if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
 502        {
 0503            item.ShowId = info.ShowId;
 0504            forceUpdate = true;
 505        }
 506
 0507        var channelId = channel.Id;
 0508        if (!item.ParentId.Equals(channelId))
 509        {
 0510            item.ParentId = channel.Id;
 0511            forceUpdate = true;
 512        }
 513
 0514        item.Audio = info.Audio;
 0515        item.ChannelId = channelId;
 0516        item.CommunityRating = info.CommunityRating;
 0517        item.EpisodeTitle = info.EpisodeTitle;
 0518        item.ExternalId = info.Id;
 519
 0520        var seriesId = info.SeriesId;
 0521        if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ord
 522        {
 0523            item.ExternalSeriesId = seriesId;
 0524            forceUpdate = true;
 525        }
 526
 0527        var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
 0528        if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle))
 529        {
 0530            item.SeriesName = info.Name;
 531        }
 532
 0533        var tags = new List<string>();
 0534        if (info.IsLive)
 535        {
 0536            tags.Add("Live");
 537        }
 538
 0539        if (info.IsPremiere)
 540        {
 0541            tags.Add("Premiere");
 542        }
 543
 0544        if (info.IsNews)
 545        {
 0546            tags.Add("News");
 547        }
 548
 0549        if (info.IsSports)
 550        {
 0551            tags.Add("Sports");
 552        }
 553
 0554        if (info.IsKids)
 555        {
 0556            tags.Add("Kids");
 557        }
 558
 0559        if (info.IsRepeat)
 560        {
 0561            tags.Add("Repeat");
 562        }
 563
 0564        if (info.IsMovie)
 565        {
 0566            tags.Add("Movie");
 567        }
 568
 0569        if (isSeries)
 570        {
 0571            tags.Add("Series");
 572        }
 573
 0574        item.Tags = tags.ToArray();
 0575        item.Genres = info.Genres.ToArray();
 576
 0577        if (info.IsHD ?? false)
 578        {
 0579            item.Width = 1280;
 0580            item.Height = 720;
 581        }
 582
 0583        item.IsMovie = info.IsMovie;
 0584        item.IsRepeat = info.IsRepeat;
 0585        if (item.IsSeries != isSeries)
 586        {
 0587            item.IsSeries = isSeries;
 0588            forceUpdate = true;
 589        }
 590
 0591        item.Name = info.Name;
 0592        item.OfficialRating = info.OfficialRating;
 0593        item.Overview = info.Overview;
 0594        item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
 0595        foreach (var providerId in info.SeriesProviderIds)
 596        {
 0597            info.ProviderIds["Series" + providerId.Key] = providerId.Value;
 598        }
 599
 0600        item.ProviderIds = info.ProviderIds;
 0601        if (item.StartDate != info.StartDate)
 602        {
 0603            item.StartDate = info.StartDate;
 0604            forceUpdate = true;
 605        }
 606
 0607        if (item.EndDate != info.EndDate)
 608        {
 0609            item.EndDate = info.EndDate;
 0610            forceUpdate = true;
 611        }
 612
 0613        item.ProductionYear = info.ProductionYear;
 0614        if (!isSeries || info.IsRepeat)
 615        {
 0616            item.PremiereDate = info.OriginalAirDate;
 617        }
 618
 0619        item.IndexNumber = info.EpisodeNumber;
 0620        item.ParentIndexNumber = info.SeasonNumber;
 621
 0622        forceUpdate |= UpdateImages(item, info);
 623
 0624        if (isNew)
 625        {
 0626            item.OnMetadataChanged();
 627
 0628            return (item, true, false);
 629        }
 630
 0631        var isUpdated = forceUpdate;
 0632        var etag = info.Etag;
 0633        if (string.IsNullOrWhiteSpace(etag))
 634        {
 0635            isUpdated = true;
 636        }
 0637        else if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
 638        {
 0639            item.SetProviderId(EtagKey, etag);
 0640            isUpdated = true;
 641        }
 642
 0643        if (isUpdated)
 644        {
 0645            item.OnMetadataChanged();
 646
 0647            return (item, false, true);
 648        }
 649
 0650        return (item, false, false);
 651    }
 652
 653    private static bool UpdateImages(BaseItem item, ProgramInfo info)
 654    {
 0655        var updated = false;
 656
 657        // Primary
 0658        updated |= UpdateImage(ImageType.Primary, item, info);
 659
 660        // Thumbnail
 0661        updated |= UpdateImage(ImageType.Thumb, item, info);
 662
 663        // Logo
 0664        updated |= UpdateImage(ImageType.Logo, item, info);
 665
 666        // Backdrop
 0667        updated |= UpdateImage(ImageType.Backdrop, item, info);
 668
 0669        return updated;
 670    }
 671
 672    private static bool UpdateImage(ImageType imageType, BaseItem item, ProgramInfo info)
 673    {
 0674        var image = item.GetImages(imageType).FirstOrDefault();
 0675        var currentImagePath = image?.Path;
 0676        var newImagePath = imageType switch
 0677        {
 0678            ImageType.Primary => info.ImagePath,
 0679            _ => null
 0680        };
 0681        var newImageUrl = imageType switch
 0682        {
 0683            ImageType.Backdrop => info.BackdropImageUrl,
 0684            ImageType.Logo => info.LogoImageUrl,
 0685            ImageType.Primary => info.ImageUrl,
 0686            ImageType.Thumb => info.ThumbImageUrl,
 0687            _ => null
 0688        };
 689
 0690        var sameImage = (currentImagePath?.Equals(newImageUrl, StringComparison.OrdinalIgnoreCase) ?? false)
 0691                                || (currentImagePath?.Equals(newImagePath, StringComparison.OrdinalIgnoreCase) ?? false)
 0692        if (sameImage)
 693        {
 0694            return false;
 695        }
 696
 0697        if (!string.IsNullOrWhiteSpace(newImagePath))
 698        {
 0699            item.SetImage(
 0700                new ItemImageInfo
 0701                {
 0702                    Path = newImagePath,
 0703                    Type = imageType
 0704                },
 0705                0);
 706
 0707            return true;
 708        }
 709
 0710        if (!string.IsNullOrWhiteSpace(newImageUrl))
 711        {
 0712            item.SetImage(
 0713                new ItemImageInfo
 0714                {
 0715                    Path = newImageUrl,
 0716                    Type = imageType
 0717                },
 0718                0);
 719
 0720            return true;
 721        }
 722
 0723        item.RemoveImage(image);
 724
 0725        return false;
 726    }
 727
 728    private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
 729    {
 0730        var sdLimitActive = _schedulesDirectService.IsImageDailyLimitActive();
 731
 0732        await Parallel.ForEachAsync(
 0733            programs
 0734                .Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
 0735                .Where(p => !sdLimitActive || !p.ImageInfos.All(
 0736                    img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
 0737                .DistinctBy(p => p.Id),
 0738            _cacheParallelOptions,
 0739            async (program, cancellationToken) =>
 0740            {
 0741                // Re-check: limit may have been set by a parallel task since the LINQ filter ran.
 0742                if (_schedulesDirectService.IsImageDailyLimitActive()
 0743                    && program.ImageInfos.All(
 0744                        img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCas
 0745                {
 0746                    return;
 0747                }
 0748
 0749                for (var i = 0; i < program.ImageInfos.Length; i++)
 0750                {
 0751                    if (cancellationToken.IsCancellationRequested)
 0752                    {
 0753                        return;
 0754                    }
 0755
 0756                    var imageInfo = program.ImageInfos[i];
 0757                    if (imageInfo.IsLocalFile)
 0758                    {
 0759                        continue;
 0760                    }
 0761
 0762                    // Skip SD downloads once the daily limit has been hit.
 0763                    if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)
 0764                        && _schedulesDirectService.IsImageDailyLimitActive())
 0765                    {
 0766                        continue;
 0767                    }
 0768
 0769                    _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
 0770                    try
 0771                    {
 0772                        program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
 0773                                program,
 0774                                imageInfo,
 0775                                imageIndex: 0,
 0776                                removeOnFailure: false)
 0777                            .ConfigureAwait(false);
 0778                    }
 0779                    catch (Exception ex)
 0780                    {
 0781                        _logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
 0782                    }
 0783                }
 0784            }).ConfigureAwait(false);
 0785    }
 786}