< 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: 405
Coverable lines: 428
Total lines: 800
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/1/2026 - 12:13:05 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: 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: 800 5/1/2026 - 12:13:05 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: 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: 800

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%411032.43%
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    {
 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        var currentPrimary = item.GetImageInfo(ImageType.Primary, 0);
 0453        var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl);
 454
 455        // Update channel image if image URL has changed
 0456        if (currentPrimary is null
 0457            || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal)))
 458        {
 0459            if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
 460            {
 0461                item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
 0462                forceUpdate = true;
 463            }
 0464            else if (!imageUrlIsNull)
 465            {
 0466                item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
 0467                forceUpdate = true;
 468            }
 469        }
 470
 0471        if (isNew)
 472        {
 0473            _libraryManager.CreateItem(item, parentFolder);
 474        }
 0475        else if (forceUpdate)
 476        {
 0477            await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).
 478        }
 479
 0480        return item;
 0481    }
 482
 483    private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(
 484        ProgramInfo info,
 485        Dictionary<Guid, LiveTvProgram> allExistingPrograms,
 486        LiveTvChannel channel)
 487    {
 0488        var id = _tvDtoService.GetInternalProgramId(info.Id);
 489
 0490        var isNew = false;
 0491        var forceUpdate = false;
 492
 0493        if (!allExistingPrograms.TryGetValue(id, out var item))
 494        {
 0495            isNew = true;
 0496            item = new LiveTvProgram
 0497            {
 0498                Name = info.Name,
 0499                Id = id,
 0500                DateCreated = DateTime.UtcNow,
 0501                DateModified = DateTime.UtcNow
 0502            };
 503        }
 0504        else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey)))
 505        {
 506            // XMLTV ETags are generated from the final ProgramInfo fields Jellyfin consumes,
 507            // so an exact match means nothing relevant changed. Other providers stay on the
 508            // field-by-field update path.
 0509            return (item, false, false);
 510        }
 511
 0512        if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
 513        {
 0514            item.ShowId = info.ShowId;
 0515            forceUpdate = true;
 516        }
 517
 0518        var channelId = channel.Id;
 0519        if (!item.ParentId.Equals(channelId))
 520        {
 0521            item.ParentId = channel.Id;
 0522            forceUpdate = true;
 523        }
 524
 0525        item.Audio = info.Audio;
 0526        item.ChannelId = channelId;
 0527        item.CommunityRating = info.CommunityRating;
 0528        item.EpisodeTitle = info.EpisodeTitle;
 0529        item.ExternalId = info.Id;
 530
 0531        var seriesId = info.SeriesId;
 0532        if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ord
 533        {
 0534            item.ExternalSeriesId = seriesId;
 0535            forceUpdate = true;
 536        }
 537
 0538        var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
 0539        if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle))
 540        {
 0541            item.SeriesName = info.Name;
 542        }
 543
 0544        var tags = new List<string>();
 0545        if (info.IsLive)
 546        {
 0547            tags.Add("Live");
 548        }
 549
 0550        if (info.IsPremiere)
 551        {
 0552            tags.Add("Premiere");
 553        }
 554
 0555        if (info.IsNews)
 556        {
 0557            tags.Add("News");
 558        }
 559
 0560        if (info.IsSports)
 561        {
 0562            tags.Add("Sports");
 563        }
 564
 0565        if (info.IsKids)
 566        {
 0567            tags.Add("Kids");
 568        }
 569
 0570        if (info.IsRepeat)
 571        {
 0572            tags.Add("Repeat");
 573        }
 574
 0575        if (info.IsMovie)
 576        {
 0577            tags.Add("Movie");
 578        }
 579
 0580        if (isSeries)
 581        {
 0582            tags.Add("Series");
 583        }
 584
 0585        item.Tags = tags.ToArray();
 0586        item.Genres = info.Genres.ToArray();
 587
 0588        if (info.IsHD ?? false)
 589        {
 0590            item.Width = 1280;
 0591            item.Height = 720;
 592        }
 593
 0594        item.IsMovie = info.IsMovie;
 0595        item.IsRepeat = info.IsRepeat;
 0596        if (item.IsSeries != isSeries)
 597        {
 0598            item.IsSeries = isSeries;
 0599            forceUpdate = true;
 600        }
 601
 0602        item.Name = info.Name;
 0603        item.OfficialRating = info.OfficialRating;
 0604        item.Overview = info.Overview;
 0605        item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
 0606        foreach (var providerId in info.SeriesProviderIds)
 607        {
 0608            info.ProviderIds["Series" + providerId.Key] = providerId.Value;
 609        }
 610
 0611        item.ProviderIds = info.ProviderIds;
 0612        if (item.StartDate != info.StartDate)
 613        {
 0614            item.StartDate = info.StartDate;
 0615            forceUpdate = true;
 616        }
 617
 0618        if (item.EndDate != info.EndDate)
 619        {
 0620            item.EndDate = info.EndDate;
 0621            forceUpdate = true;
 622        }
 623
 0624        item.ProductionYear = info.ProductionYear;
 0625        if (!isSeries || info.IsRepeat)
 626        {
 0627            item.PremiereDate = info.OriginalAirDate;
 628        }
 629
 0630        item.IndexNumber = info.EpisodeNumber;
 0631        item.ParentIndexNumber = info.SeasonNumber;
 632
 0633        forceUpdate |= UpdateImages(item, info);
 634
 635        // Restore the etag wiped by `item.ProviderIds = info.ProviderIds` above and
 636        // persist it on new items so they join the fast path on the next refresh
 637        // instead of taking an extra full processing cycle.
 0638        var isUpdated = forceUpdate;
 0639        var etag = info.Etag;
 0640        if (string.IsNullOrWhiteSpace(etag))
 641        {
 0642            isUpdated = true;
 643        }
 0644        else if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
 645        {
 0646            item.SetProviderId(EtagKey, etag);
 0647            isUpdated = true;
 648        }
 649
 0650        if (isNew)
 651        {
 0652            item.OnMetadataChanged();
 653
 0654            return (item, true, false);
 655        }
 656
 0657        if (isUpdated)
 658        {
 0659            item.OnMetadataChanged();
 660
 0661            return (item, false, true);
 662        }
 663
 0664        return (item, false, false);
 665    }
 666
 667    private static bool UpdateImages(BaseItem item, ProgramInfo info)
 668    {
 0669        var updated = false;
 670
 671        // Primary
 0672        updated |= UpdateImage(ImageType.Primary, item, info);
 673
 674        // Thumbnail
 0675        updated |= UpdateImage(ImageType.Thumb, item, info);
 676
 677        // Logo
 0678        updated |= UpdateImage(ImageType.Logo, item, info);
 679
 680        // Backdrop
 0681        updated |= UpdateImage(ImageType.Backdrop, item, info);
 682
 0683        return updated;
 684    }
 685
 686    private static bool UpdateImage(ImageType imageType, BaseItem item, ProgramInfo info)
 687    {
 0688        var image = item.GetImages(imageType).FirstOrDefault();
 0689        var currentImagePath = image?.Path;
 0690        var newImagePath = imageType switch
 0691        {
 0692            ImageType.Primary => info.ImagePath,
 0693            _ => null
 0694        };
 0695        var newImageUrl = imageType switch
 0696        {
 0697            ImageType.Backdrop => info.BackdropImageUrl,
 0698            ImageType.Logo => info.LogoImageUrl,
 0699            ImageType.Primary => info.ImageUrl,
 0700            ImageType.Thumb => info.ThumbImageUrl,
 0701            _ => null
 0702        };
 703
 0704        var sameImage = (currentImagePath?.Equals(newImageUrl, StringComparison.OrdinalIgnoreCase) ?? false)
 0705                                || (currentImagePath?.Equals(newImagePath, StringComparison.OrdinalIgnoreCase) ?? false)
 0706        if (sameImage)
 707        {
 0708            return false;
 709        }
 710
 0711        if (!string.IsNullOrWhiteSpace(newImagePath))
 712        {
 0713            item.SetImage(
 0714                new ItemImageInfo
 0715                {
 0716                    Path = newImagePath,
 0717                    Type = imageType
 0718                },
 0719                0);
 720
 0721            return true;
 722        }
 723
 0724        if (!string.IsNullOrWhiteSpace(newImageUrl))
 725        {
 0726            item.SetImage(
 0727                new ItemImageInfo
 0728                {
 0729                    Path = newImageUrl,
 0730                    Type = imageType
 0731                },
 0732                0);
 733
 0734            return true;
 735        }
 736
 0737        item.RemoveImage(image);
 738
 0739        return false;
 740    }
 741
 742    private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
 743    {
 0744        var sdLimitActive = _schedulesDirectService.IsImageDailyLimitActive();
 745
 0746        await Parallel.ForEachAsync(
 0747            programs
 0748                .Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
 0749                .Where(p => !sdLimitActive || !p.ImageInfos.All(
 0750                    img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
 0751                .DistinctBy(p => p.Id),
 0752            _cacheParallelOptions,
 0753            async (program, cancellationToken) =>
 0754            {
 0755                // Re-check: limit may have been set by a parallel task since the LINQ filter ran.
 0756                if (_schedulesDirectService.IsImageDailyLimitActive()
 0757                    && program.ImageInfos.All(
 0758                        img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCas
 0759                {
 0760                    return;
 0761                }
 0762
 0763                for (var i = 0; i < program.ImageInfos.Length; i++)
 0764                {
 0765                    if (cancellationToken.IsCancellationRequested)
 0766                    {
 0767                        return;
 0768                    }
 0769
 0770                    var imageInfo = program.ImageInfos[i];
 0771                    if (imageInfo.IsLocalFile)
 0772                    {
 0773                        continue;
 0774                    }
 0775
 0776                    // Skip SD downloads once the daily limit has been hit.
 0777                    if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)
 0778                        && _schedulesDirectService.IsImageDailyLimitActive())
 0779                    {
 0780                        continue;
 0781                    }
 0782
 0783                    _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
 0784                    try
 0785                    {
 0786                        program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
 0787                                program,
 0788                                imageInfo,
 0789                                imageIndex: 0,
 0790                                removeOnFailure: false)
 0791                            .ConfigureAwait(false);
 0792                    }
 0793                    catch (Exception ex)
 0794                    {
 0795                        _logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
 0796                    }
 0797                }
 0798            }).ConfigureAwait(false);
 0799    }
 800}