< 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: 409
Coverable lines: 432
Total lines: 808
Line coverage: 5.3%
Branch coverage
1%
Covered branches: 2
Total branches: 149
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 5/16/2026 - 12:15:55 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: 7787/22/2026 - 12:16:22 AM Line coverage: 5.4% (23/420) Branch coverage: 1.4% (2/141) Total lines: 7878/6/2026 - 12:17:15 AM Line coverage: 5.3% (23/428) Branch coverage: 1.3% (2/149) Total lines: 80010/29/2026 - 12:17:11 AM Line coverage: 5.3% (23/432) Branch coverage: 1.3% (2/149) Total lines: 808 5/16/2026 - 12:15:55 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: 7787/22/2026 - 12:16:22 AM Line coverage: 5.4% (23/420) Branch coverage: 1.4% (2/141) Total lines: 7878/6/2026 - 12:17:15 AM Line coverage: 5.3% (23/428) Branch coverage: 1.3% (2/149) Total lines: 80010/29/2026 - 12:17:11 AM Line coverage: 5.3% (23/432) Branch coverage: 1.3% (2/149) Total lines: 808

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
GetGuideInfo()100%210%
RefreshGuide()20%421031.57%
GetGuideDays()0%620%
RefreshChannelsInternal()0%342180%
CleanDatabase(...)0%7280%
GetChannel()0%930300%
GetProgram(...)0%3422580%
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 Jellyfin.LiveTv.Listings;
 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    {
 2774        _logger = logger;
 2775        _config = config;
 2776        _fileSystem = fileSystem;
 2777        _itemRepo = itemRepo;
 2778        _libraryManager = libraryManager;
 2779        _liveTvManager = liveTvManager;
 2780        _tunerHostManager = tunerHostManager;
 2781        _recordingsManager = recordingsManager;
 2782        _schedulesDirectService = schedulesDirectService;
 2783        _tvDtoService = tvDtoService;
 2784    }
 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 (channelIds, programIds, hasErrors) = await RefreshChannelsInternal(service, innerProgress, cancella
 129
 0130                newChannelIdList.AddRange(channelIds);
 0131                newProgramIdList.AddRange(programIds);
 132
 133                // The channels that failed did not report any programs, so cleaning the database
 134                // would delete every program they provide instead of keeping the previous ones.
 0135                cleanDatabase &= !hasErrors;
 0136            }
 0137            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 138            {
 0139                throw;
 140            }
 0141            catch (Exception ex)
 142            {
 0143                cleanDatabase = false;
 0144                _logger.LogError(ex, "Error refreshing channels for service");
 0145            }
 146
 0147            numComplete++;
 0148            double percent = numComplete;
 0149            percent /= _liveTvManager.Services.Count;
 150
 0151            progress.Report(100 * percent);
 152        }
 153
 0154        if (cleanDatabase)
 155        {
 0156            CleanDatabase(newChannelIdList.ToArray(), [BaseItemKind.LiveTvChannel], progress, cancellationToken);
 0157            CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken);
 158        }
 159
 0160        var coreService = _liveTvManager.Services.OfType<DefaultLiveTvService>().FirstOrDefault();
 0161        if (coreService is not null)
 162        {
 0163            await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
 0164            await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false);
 165        }
 166
 0167        progress.Report(100);
 0168    }
 169
 170    private double GetGuideDays()
 171    {
 0172        var config = _config.GetLiveTvConfiguration();
 173
 0174        return config.GuideDays.HasValue
 0175            ? Math.Clamp(config.GuideDays.Value, 1, MaxGuideDays)
 0176            : 7;
 177    }
 178
 179    private async Task<(List<Guid> ChannelIds, List<Guid> ProgramIds, bool HasErrors)> RefreshChannelsInternal(ILiveTvSe
 180    {
 0181        progress.Report(10);
 182
 0183        var hasErrors = false;
 184
 0185        var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
 0186            .Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
 0187            .ToList();
 188
 0189        var list = new List<LiveTvChannel>();
 190
 0191        var numComplete = 0;
 0192        var parentFolder = _liveTvManager.GetInternalLiveTvFolder(cancellationToken);
 193
 0194        foreach (var channelInfo in allChannelsList)
 195        {
 0196            cancellationToken.ThrowIfCancellationRequested();
 197
 198            try
 199            {
 0200                var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).Confi
 201
 0202                list.Add(item);
 0203            }
 0204            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 205            {
 0206                throw;
 207            }
 0208            catch (Exception ex)
 209            {
 0210                hasErrors = true;
 0211                _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
 0212            }
 213
 0214            numComplete++;
 0215            double percent = numComplete;
 0216            percent /= allChannelsList.Count;
 217
 0218            progress.Report((5 * percent) + 10);
 0219        }
 220
 0221        progress.Report(15);
 222
 0223        numComplete = 0;
 0224        var programIds = new List<Guid>();
 0225        var channels = new List<Guid>();
 226
 0227        var guideDays = GetGuideDays();
 228
 0229        _logger.LogInformation("Refreshing guide with {Days} days of guide data", guideDays);
 230
 0231        var maxCacheDate = DateTime.UtcNow.AddDays(MaxCacheDays);
 0232        foreach (var currentChannel in list)
 233        {
 0234            cancellationToken.ThrowIfCancellationRequested();
 0235            channels.Add(currentChannel.Id);
 236
 237            try
 238            {
 0239                var start = DateTime.UtcNow.AddHours(-1);
 0240                var end = start.AddDays(guideDays);
 241
 0242                var isMovie = false;
 0243                var isSports = false;
 0244                var isNews = false;
 0245                var isKids = false;
 0246                var isSeries = false;
 247
 0248                var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellatio
 249
 0250                var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
 0251                {
 0252                    IncludeItemTypes = [BaseItemKind.LiveTvProgram],
 0253                    ChannelIds = [currentChannel.Id],
 0254                    DtoOptions = new DtoOptions(true)
 0255                }).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
 256
 0257                var newPrograms = new List<LiveTvProgram>();
 0258                var updatedPrograms = new List<LiveTvProgram>();
 259
 0260                foreach (var program in channelPrograms)
 261                {
 0262                    var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel);
 0263                    var id = programItem.Id;
 0264                    if (isNew)
 265                    {
 0266                        newPrograms.Add(programItem);
 267                    }
 0268                    else if (isUpdated)
 269                    {
 0270                        updatedPrograms.Add(programItem);
 271                    }
 272
 0273                    programIds.Add(programItem.Id);
 274
 0275                    isMovie |= program.IsMovie;
 0276                    isSeries |= program.IsSeries;
 0277                    isSports |= program.IsSports;
 0278                    isNews |= program.IsNews;
 0279                    isKids |= program.IsKids;
 280                }
 281
 0282                _logger.LogDebug(
 0283                    "Channel {Name} has {NewCount} new programs and {UpdatedCount} updated programs",
 0284                    currentChannel.Name,
 0285                    newPrograms.Count,
 0286                    updatedPrograms.Count);
 287
 0288                if (newPrograms.Count > 0)
 289                {
 0290                    _libraryManager.CreateItems(newPrograms, currentChannel, cancellationToken);
 291
 0292                    await PreCacheImages(newPrograms, maxCacheDate).ConfigureAwait(false);
 293                }
 294
 0295                if (updatedPrograms.Count > 0)
 296                {
 0297                    await _libraryManager.UpdateItemsAsync(
 0298                        updatedPrograms,
 0299                        currentChannel,
 0300                        ItemUpdateType.MetadataImport,
 0301                        cancellationToken).ConfigureAwait(false);
 302
 0303                    await PreCacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false);
 304                }
 305
 0306                currentChannel.IsMovie = isMovie;
 0307                currentChannel.IsNews = isNews;
 0308                currentChannel.IsSports = isSports;
 0309                currentChannel.IsSeries = isSeries;
 310
 0311                if (isKids)
 312                {
 0313                    currentChannel.AddTag("Kids");
 314                }
 315
 0316                await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).Configure
 0317                await currentChannel.RefreshMetadata(
 0318                    new MetadataRefreshOptions(new DirectoryService(_fileSystem))
 0319                    {
 0320                        ForceSave = true
 0321                    },
 0322                    cancellationToken).ConfigureAwait(false);
 0323            }
 0324            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 325            {
 0326                throw;
 327            }
 0328            catch (Exception ex)
 329            {
 0330                hasErrors = true;
 0331                _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
 0332            }
 333
 0334            numComplete++;
 0335            double percent = numComplete / (double)allChannelsList.Count;
 336
 0337            progress.Report((85 * percent) + 15);
 0338        }
 339
 0340        progress.Report(100);
 0341        return (channels, programIds, hasErrors);
 0342    }
 343
 344    private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, Cancellation
 345    {
 0346        var list = _itemRepo.GetItemIdsList(new InternalItemsQuery
 0347        {
 0348            IncludeItemTypes = validTypes,
 0349            DtoOptions = new DtoOptions(false)
 0350        });
 351
 0352        var numComplete = 0;
 353
 0354        foreach (var itemId in list)
 355        {
 0356            cancellationToken.ThrowIfCancellationRequested();
 357
 0358            if (itemId.IsEmpty())
 359            {
 360                // Somehow some invalid data got into the db. It probably predates the boundary checking
 361                continue;
 362            }
 363
 0364            if (!currentIdList.Contains(itemId))
 365            {
 0366                var item = _libraryManager.GetItemById(itemId);
 367
 0368                if (item is not null)
 369                {
 0370                    _libraryManager.DeleteItem(
 0371                        item,
 0372                        new DeleteOptions
 0373                        {
 0374                            DeleteFileLocation = false,
 0375                            DeleteFromExternalProvider = false
 0376                        },
 0377                        false);
 378                }
 379            }
 380
 0381            numComplete++;
 0382            double percent = numComplete / (double)list.Count;
 383
 0384            progress.Report(100 * percent);
 385        }
 0386    }
 387
 388    private async Task<LiveTvChannel> GetChannel(
 389        ChannelInfo channelInfo,
 390        string serviceName,
 391        BaseItem parentFolder,
 392        CancellationToken cancellationToken)
 393    {
 0394        var parentFolderId = parentFolder.Id;
 0395        var isNew = false;
 0396        var forceUpdate = false;
 397
 0398        var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id);
 399
 0400        if (_libraryManager.GetItemById(id) is not LiveTvChannel item)
 401        {
 0402            item = new LiveTvChannel
 0403            {
 0404                Name = channelInfo.Name,
 0405                Id = id,
 0406                DateCreated = DateTime.UtcNow
 0407            };
 408
 0409            isNew = true;
 410        }
 411
 0412        if (channelInfo.Tags is not null)
 413        {
 0414            if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase))
 415            {
 0416                isNew = true;
 417            }
 418
 0419            item.Tags = channelInfo.Tags;
 420        }
 421
 0422        if (!item.ParentId.Equals(parentFolderId))
 423        {
 0424            isNew = true;
 425        }
 426
 0427        item.ParentId = parentFolderId;
 428
 0429        item.ChannelType = channelInfo.ChannelType;
 0430        item.ServiceName = serviceName;
 431
 0432        if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase))
 433        {
 0434            forceUpdate = true;
 435        }
 436
 0437        item.SetProviderId(ExternalServiceTag, serviceName);
 438
 0439        if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal))
 440        {
 0441            forceUpdate = true;
 442        }
 443
 0444        item.ExternalId = channelInfo.Id;
 445
 0446        if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal))
 447        {
 0448            forceUpdate = true;
 449        }
 450
 0451        item.Number = channelInfo.Number;
 452
 0453        if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal))
 454        {
 0455            forceUpdate = true;
 456        }
 457
 0458        item.Name = channelInfo.Name;
 459
 0460        var currentPrimary = item.GetImageInfo(ImageType.Primary, 0);
 0461        var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl);
 462
 463        // Update channel image if image URL has changed
 0464        if (currentPrimary is null
 0465            || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal)))
 466        {
 0467            if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
 468            {
 0469                item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
 0470                forceUpdate = true;
 471            }
 0472            else if (!imageUrlIsNull)
 473            {
 0474                item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
 0475                forceUpdate = true;
 476            }
 477        }
 478
 0479        if (isNew)
 480        {
 0481            _libraryManager.CreateItem(item, parentFolder);
 482        }
 0483        else if (forceUpdate)
 484        {
 0485            await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).
 486        }
 487
 0488        return item;
 0489    }
 490
 491    private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(
 492        ProgramInfo info,
 493        Dictionary<Guid, LiveTvProgram> allExistingPrograms,
 494        LiveTvChannel channel)
 495    {
 0496        var id = _tvDtoService.GetInternalProgramId(info.Id);
 497
 0498        var isNew = false;
 0499        var forceUpdate = false;
 500
 0501        if (!allExistingPrograms.TryGetValue(id, out var item))
 502        {
 0503            isNew = true;
 0504            item = new LiveTvProgram
 0505            {
 0506                Name = info.Name,
 0507                Id = id,
 0508                DateCreated = DateTime.UtcNow,
 0509                DateModified = DateTime.UtcNow
 0510            };
 511        }
 0512        else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey)))
 513        {
 514            // XMLTV ETags are generated from the final ProgramInfo fields Jellyfin consumes,
 515            // so an exact match means nothing relevant changed. Other providers stay on the
 516            // field-by-field update path.
 0517            return (item, false, false);
 518        }
 519
 0520        if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
 521        {
 0522            item.ShowId = info.ShowId;
 0523            forceUpdate = true;
 524        }
 525
 0526        var channelId = channel.Id;
 0527        if (!item.ParentId.Equals(channelId))
 528        {
 0529            item.ParentId = channel.Id;
 0530            forceUpdate = true;
 531        }
 532
 0533        item.Audio = info.Audio;
 0534        item.ChannelId = channelId;
 0535        item.CommunityRating = info.CommunityRating;
 0536        item.EpisodeTitle = info.EpisodeTitle;
 0537        item.ExternalId = info.Id;
 538
 0539        var seriesId = info.SeriesId;
 0540        if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ord
 541        {
 0542            item.ExternalSeriesId = seriesId;
 0543            forceUpdate = true;
 544        }
 545
 0546        var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
 0547        if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle))
 548        {
 0549            item.SeriesName = info.Name;
 550        }
 551
 0552        var tags = new List<string>();
 0553        if (info.IsLive)
 554        {
 0555            tags.Add("Live");
 556        }
 557
 0558        if (info.IsPremiere)
 559        {
 0560            tags.Add("Premiere");
 561        }
 562
 0563        if (info.IsNews)
 564        {
 0565            tags.Add("News");
 566        }
 567
 0568        if (info.IsSports)
 569        {
 0570            tags.Add("Sports");
 571        }
 572
 0573        if (info.IsKids)
 574        {
 0575            tags.Add("Kids");
 576        }
 577
 0578        if (info.IsRepeat)
 579        {
 0580            tags.Add("Repeat");
 581        }
 582
 0583        if (info.IsMovie)
 584        {
 0585            tags.Add("Movie");
 586        }
 587
 0588        if (isSeries)
 589        {
 0590            tags.Add("Series");
 591        }
 592
 0593        item.Tags = tags.ToArray();
 0594        item.Genres = info.Genres.ToArray();
 595
 0596        if (info.IsHD ?? false)
 597        {
 0598            item.Width = 1280;
 0599            item.Height = 720;
 600        }
 601
 0602        item.IsMovie = info.IsMovie;
 0603        item.IsRepeat = info.IsRepeat;
 0604        if (item.IsSeries != isSeries)
 605        {
 0606            item.IsSeries = isSeries;
 0607            forceUpdate = true;
 608        }
 609
 0610        item.Name = info.Name;
 0611        item.OfficialRating = info.OfficialRating;
 0612        item.Overview = info.Overview;
 0613        item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
 0614        foreach (var providerId in info.SeriesProviderIds)
 615        {
 0616            info.ProviderIds["Series" + providerId.Key] = providerId.Value;
 617        }
 618
 0619        item.ProviderIds = info.ProviderIds;
 0620        if (item.StartDate != info.StartDate)
 621        {
 0622            item.StartDate = info.StartDate;
 0623            forceUpdate = true;
 624        }
 625
 0626        if (item.EndDate != info.EndDate)
 627        {
 0628            item.EndDate = info.EndDate;
 0629            forceUpdate = true;
 630        }
 631
 0632        item.ProductionYear = info.ProductionYear;
 0633        if (!isSeries || info.IsRepeat)
 634        {
 0635            item.PremiereDate = info.OriginalAirDate;
 636        }
 637
 0638        item.IndexNumber = info.EpisodeNumber;
 0639        item.ParentIndexNumber = info.SeasonNumber;
 640
 0641        forceUpdate |= UpdateImages(item, info);
 642
 643        // Restore the etag wiped by `item.ProviderIds = info.ProviderIds` above and
 644        // persist it on new items so they join the fast path on the next refresh
 645        // instead of taking an extra full processing cycle.
 0646        var isUpdated = forceUpdate;
 0647        var etag = info.Etag;
 0648        if (string.IsNullOrWhiteSpace(etag))
 649        {
 0650            isUpdated = true;
 651        }
 0652        else if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
 653        {
 0654            item.SetProviderId(EtagKey, etag);
 0655            isUpdated = true;
 656        }
 657
 0658        if (isNew)
 659        {
 0660            item.OnMetadataChanged();
 661
 0662            return (item, true, false);
 663        }
 664
 0665        if (isUpdated)
 666        {
 0667            item.OnMetadataChanged();
 668
 0669            return (item, false, true);
 670        }
 671
 0672        return (item, false, false);
 673    }
 674
 675    private static bool UpdateImages(BaseItem item, ProgramInfo info)
 676    {
 0677        var updated = false;
 678
 679        // Primary
 0680        updated |= UpdateImage(ImageType.Primary, item, info);
 681
 682        // Thumbnail
 0683        updated |= UpdateImage(ImageType.Thumb, item, info);
 684
 685        // Logo
 0686        updated |= UpdateImage(ImageType.Logo, item, info);
 687
 688        // Backdrop
 0689        updated |= UpdateImage(ImageType.Backdrop, item, info);
 690
 0691        return updated;
 692    }
 693
 694    private static bool UpdateImage(ImageType imageType, BaseItem item, ProgramInfo info)
 695    {
 0696        var image = item.GetImages(imageType).FirstOrDefault();
 0697        var currentImagePath = image?.Path;
 0698        var newImagePath = imageType switch
 0699        {
 0700            ImageType.Primary => info.ImagePath,
 0701            _ => null
 0702        };
 0703        var newImageUrl = imageType switch
 0704        {
 0705            ImageType.Backdrop => info.BackdropImageUrl,
 0706            ImageType.Logo => info.LogoImageUrl,
 0707            ImageType.Primary => info.ImageUrl,
 0708            ImageType.Thumb => info.ThumbImageUrl,
 0709            _ => null
 0710        };
 711
 0712        var sameImage = (currentImagePath?.Equals(newImageUrl, StringComparison.OrdinalIgnoreCase) ?? false)
 0713                                || (currentImagePath?.Equals(newImagePath, StringComparison.OrdinalIgnoreCase) ?? false)
 0714        if (sameImage)
 715        {
 0716            return false;
 717        }
 718
 0719        if (!string.IsNullOrWhiteSpace(newImagePath))
 720        {
 0721            item.SetImage(
 0722                new ItemImageInfo
 0723                {
 0724                    Path = newImagePath,
 0725                    Type = imageType
 0726                },
 0727                0);
 728
 0729            return true;
 730        }
 731
 0732        if (!string.IsNullOrWhiteSpace(newImageUrl))
 733        {
 0734            item.SetImage(
 0735                new ItemImageInfo
 0736                {
 0737                    Path = newImageUrl,
 0738                    Type = imageType
 0739                },
 0740                0);
 741
 0742            return true;
 743        }
 744
 0745        item.RemoveImage(image);
 746
 0747        return false;
 748    }
 749
 750    private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
 751    {
 0752        var sdLimitActive = _schedulesDirectService.IsImageDailyLimitActive();
 753
 0754        await Parallel.ForEachAsync(
 0755            programs
 0756                .Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
 0757                .Where(p => !sdLimitActive || !p.ImageInfos.All(
 0758                    img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
 0759                .DistinctBy(p => p.Id),
 0760            _cacheParallelOptions,
 0761            async (program, cancellationToken) =>
 0762            {
 0763                // Re-check: limit may have been set by a parallel task since the LINQ filter ran.
 0764                if (_schedulesDirectService.IsImageDailyLimitActive()
 0765                    && program.ImageInfos.All(
 0766                        img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCas
 0767                {
 0768                    return;
 0769                }
 0770
 0771                for (var i = 0; i < program.ImageInfos.Length; i++)
 0772                {
 0773                    if (cancellationToken.IsCancellationRequested)
 0774                    {
 0775                        return;
 0776                    }
 0777
 0778                    var imageInfo = program.ImageInfos[i];
 0779                    if (imageInfo.IsLocalFile)
 0780                    {
 0781                        continue;
 0782                    }
 0783
 0784                    // Skip SD downloads once the daily limit has been hit.
 0785                    if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)
 0786                        && _schedulesDirectService.IsImageDailyLimitActive())
 0787                    {
 0788                        continue;
 0789                    }
 0790
 0791                    _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
 0792                    try
 0793                    {
 0794                        program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
 0795                                program,
 0796                                imageInfo,
 0797                                imageIndex: 0,
 0798                                removeOnFailure: false)
 0799                            .ConfigureAwait(false);
 0800                    }
 0801                    catch (Exception ex)
 0802                    {
 0803                        _logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
 0804                    }
 0805                }
 0806            }).ConfigureAwait(false);
 0807    }
 808}