From 91b2b7fc3dfe23fdc01834f2f1364e9f8bd98fe4 Mon Sep 17 00:00:00 2001 From: Christopher Young Date: Wed, 8 Oct 2025 12:27:46 -0600 Subject: added tests --- .../SyncPlay/GroupTests.cs | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs (limited to 'tests') diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs new file mode 100644 index 0000000000..f011d941cc --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.SyncPlay; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay +{ + public class GroupTests + { + [Fact] + public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible() + { + // Arrange + var mockLogger = new Mock>(); + var mockLoggerFactory = new Mock(); + mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + + var mockUserManager = new Mock(); + var mockSessionManager = new Mock(); + var mockLibraryManager = new Mock(); + + var mockItem = new Mock(); + mockItem.Setup(i => i.IsVisibleStandalone(It.IsAny())).Returns(true); + + mockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns(mockItem.Object); + + var group = new Emby.Server.Implementations.SyncPlay.Group(mockLoggerFactory.Object, mockUserManager.Object, mockSessionManager.Object, mockLibraryManager.Object); + + var itemId = Guid.NewGuid(); + var playlist = new List { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); + + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + + var user = new User("test-user", "auth-provider", "pwdreset-provider"); + + var result = group.HasAccessToPlayQueue(user); + + Assert.True(result); + } + + [Fact] + public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem() + { + // Arrange + var mockLogger = new Mock>(); + var mockLoggerFactory = new Mock(); + mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + + var mockUserManager = new Mock(); + var mockSessionManager = new Mock(); + var mockLibraryManager = new Mock(); + + var mockItem = new Mock(); + mockItem.Setup(i => i.IsVisibleStandalone(It.IsAny())).Returns(true); + + mockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns((BaseItem?)null); + Assert.Null( + mockLibraryManager.Object.GetItemById(Guid.NewGuid())); + var group = new Emby.Server.Implementations.SyncPlay.Group(mockLoggerFactory.Object, mockUserManager.Object, mockSessionManager.Object, mockLibraryManager.Object); + + var itemId = Guid.NewGuid(); + var playlist = new List { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); + + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + + var user = new User("test-user", "auth-provider", "pwdreset-provider"); + + var result = group.HasAccessToPlayQueue(user); + + Assert.False(result); + } + } +} -- cgit v1.2.3 From 790220ef6b041c3b906a2f9dbaea947e0c59d9a4 Mon Sep 17 00:00:00 2001 From: pokreman06 <112423673+pokreman06@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:38:29 -0600 Subject: Update tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs Co-authored-by: Bond-009 --- tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs | 1 - 1 file changed, 1 deletion(-) (limited to 'tests') diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs index f011d941cc..44ff31cb8d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs @@ -16,7 +16,6 @@ namespace Jellyfin.Server.Implementations.Tests.SyncPlay [Fact] public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible() { - // Arrange var mockLogger = new Mock>(); var mockLoggerFactory = new Mock(); mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); -- cgit v1.2.3 From b09c9655fd768172518f2958cc50b0b6ea3fa109 Mon Sep 17 00:00:00 2001 From: pokreman06 <112423673+pokreman06@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:38:36 -0600 Subject: Update tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs Co-authored-by: Bond-009 --- tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs | 1 - 1 file changed, 1 deletion(-) (limited to 'tests') diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs index 44ff31cb8d..1f62d07ab5 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs @@ -49,7 +49,6 @@ namespace Jellyfin.Server.Implementations.Tests.SyncPlay [Fact] public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem() { - // Arrange var mockLogger = new Mock>(); var mockLoggerFactory = new Mock(); mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); -- cgit v1.2.3 From 438d992c8b0522f6a17f437ee991c8ef6808d749 Mon Sep 17 00:00:00 2001 From: Christopher Young Date: Sat, 8 Nov 2025 07:30:58 -0700 Subject: Fixed group tests to use a file scoped namespace --- .../SyncPlay/GroupTests.cs | 99 +++++++++++----------- 1 file changed, 49 insertions(+), 50 deletions(-) (limited to 'tests') diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs index 1f62d07ab5..d854301358 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs @@ -9,75 +9,74 @@ using Microsoft.Extensions.Logging; using Moq; using Xunit; -namespace Jellyfin.Server.Implementations.Tests.SyncPlay +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class GroupTests { - public class GroupTests + [Fact] + public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible() { - [Fact] - public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible() - { - var mockLogger = new Mock>(); - var mockLoggerFactory = new Mock(); - mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + var mockLogger = new Mock>(); + var mockLoggerFactory = new Mock(); + mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); - var mockUserManager = new Mock(); - var mockSessionManager = new Mock(); - var mockLibraryManager = new Mock(); + var mockUserManager = new Mock(); + var mockSessionManager = new Mock(); + var mockLibraryManager = new Mock(); - var mockItem = new Mock(); - mockItem.Setup(i => i.IsVisibleStandalone(It.IsAny())).Returns(true); + var mockItem = new Mock(); + mockItem.Setup(i => i.IsVisibleStandalone(It.IsAny())).Returns(true); - mockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns(mockItem.Object); + mockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns(mockItem.Object); - var group = new Emby.Server.Implementations.SyncPlay.Group(mockLoggerFactory.Object, mockUserManager.Object, mockSessionManager.Object, mockLibraryManager.Object); + var group = new Emby.Server.Implementations.SyncPlay.Group(mockLoggerFactory.Object, mockUserManager.Object, mockSessionManager.Object, mockLibraryManager.Object); - var itemId = Guid.NewGuid(); - var playlist = new List { itemId }; - group.PlayQueue.Reset(); - group.PlayQueue.SetPlaylist(playlist); + var itemId = Guid.NewGuid(); + var playlist = new List { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); - Assert.Single(group.PlayQueue.GetPlaylist()); - Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); - var user = new User("test-user", "auth-provider", "pwdreset-provider"); + var user = new User("test-user", "auth-provider", "pwdreset-provider"); - var result = group.HasAccessToPlayQueue(user); + var result = group.HasAccessToPlayQueue(user); - Assert.True(result); - } + Assert.True(result); + } - [Fact] - public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem() - { - var mockLogger = new Mock>(); - var mockLoggerFactory = new Mock(); - mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + [Fact] + public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem() + { + var mockLogger = new Mock>(); + var mockLoggerFactory = new Mock(); + mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); - var mockUserManager = new Mock(); - var mockSessionManager = new Mock(); - var mockLibraryManager = new Mock(); + var mockUserManager = new Mock(); + var mockSessionManager = new Mock(); + var mockLibraryManager = new Mock(); - var mockItem = new Mock(); - mockItem.Setup(i => i.IsVisibleStandalone(It.IsAny())).Returns(true); + var mockItem = new Mock(); + mockItem.Setup(i => i.IsVisibleStandalone(It.IsAny())).Returns(true); - mockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns((BaseItem?)null); - Assert.Null( - mockLibraryManager.Object.GetItemById(Guid.NewGuid())); - var group = new Emby.Server.Implementations.SyncPlay.Group(mockLoggerFactory.Object, mockUserManager.Object, mockSessionManager.Object, mockLibraryManager.Object); + mockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns((BaseItem?)null); + Assert.Null( + mockLibraryManager.Object.GetItemById(Guid.NewGuid())); + var group = new Emby.Server.Implementations.SyncPlay.Group(mockLoggerFactory.Object, mockUserManager.Object, mockSessionManager.Object, mockLibraryManager.Object); - var itemId = Guid.NewGuid(); - var playlist = new List { itemId }; - group.PlayQueue.Reset(); - group.PlayQueue.SetPlaylist(playlist); + var itemId = Guid.NewGuid(); + var playlist = new List { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); - Assert.Single(group.PlayQueue.GetPlaylist()); - Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); - var user = new User("test-user", "auth-provider", "pwdreset-provider"); + var user = new User("test-user", "auth-provider", "pwdreset-provider"); - var result = group.HasAccessToPlayQueue(user); + var result = group.HasAccessToPlayQueue(user); - Assert.False(result); - } + Assert.False(result); } } -- cgit v1.2.3 From 88602ce90530e89668c55df69b70b4f14bdc9e8b Mon Sep 17 00:00:00 2001 From: Christopher Young Date: Sat, 8 Nov 2025 12:35:37 -0700 Subject: Refactored GroupTests. Removed duplicate mock object declarations --- .../SyncPlay/GroupTests.cs | 52 +++++++++++----------- 1 file changed, 25 insertions(+), 27 deletions(-) (limited to 'tests') diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs index d854301358..bd9e680cd9 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs @@ -13,24 +13,35 @@ namespace Jellyfin.Server.Implementations.Tests.SyncPlay; public class GroupTests { - [Fact] - public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible() + public GroupTests() { var mockLogger = new Mock>(); - var mockLoggerFactory = new Mock(); - mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + MockLoggerFactory = new Mock(); + MockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + + MockUserManager = new Mock(); + MockSessionManager = new Mock(); + MockLibraryManager = new Mock(); + MockItem = new Mock(); + MockItem.Setup(i => i.IsVisibleStandalone(It.IsAny())).Returns(true); + } + + private Mock MockLoggerFactory { get; } - var mockUserManager = new Mock(); - var mockSessionManager = new Mock(); - var mockLibraryManager = new Mock(); + private Mock MockUserManager { get; } - var mockItem = new Mock(); - mockItem.Setup(i => i.IsVisibleStandalone(It.IsAny())).Returns(true); + private Mock MockSessionManager { get; } - mockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns(mockItem.Object); + private Mock MockLibraryManager { get; } - var group = new Emby.Server.Implementations.SyncPlay.Group(mockLoggerFactory.Object, mockUserManager.Object, mockSessionManager.Object, mockLibraryManager.Object); + private Mock MockItem { get; } + [Fact] + public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible() + { + MockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns(MockItem.Object); + + var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object); var itemId = Guid.NewGuid(); var playlist = new List { itemId }; group.PlayQueue.Reset(); @@ -40,7 +51,6 @@ public class GroupTests Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); var user = new User("test-user", "auth-provider", "pwdreset-provider"); - var result = group.HasAccessToPlayQueue(user); Assert.True(result); @@ -49,22 +59,11 @@ public class GroupTests [Fact] public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem() { - var mockLogger = new Mock>(); - var mockLoggerFactory = new Mock(); - mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + MockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns((BaseItem?)null); - var mockUserManager = new Mock(); - var mockSessionManager = new Mock(); - var mockLibraryManager = new Mock(); - - var mockItem = new Mock(); - mockItem.Setup(i => i.IsVisibleStandalone(It.IsAny())).Returns(true); - - mockLibraryManager.Setup(m => m.GetItemById(It.IsAny())).Returns((BaseItem?)null); - Assert.Null( - mockLibraryManager.Object.GetItemById(Guid.NewGuid())); - var group = new Emby.Server.Implementations.SyncPlay.Group(mockLoggerFactory.Object, mockUserManager.Object, mockSessionManager.Object, mockLibraryManager.Object); + Assert.Null(MockLibraryManager.Object.GetItemById(Guid.NewGuid())); + var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object); var itemId = Guid.NewGuid(); var playlist = new List { itemId }; group.PlayQueue.Reset(); @@ -74,7 +73,6 @@ public class GroupTests Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); var user = new User("test-user", "auth-provider", "pwdreset-provider"); - var result = group.HasAccessToPlayQueue(user); Assert.False(result); -- cgit v1.2.3 From 372c1681d8272c6fa8f120a132bc40351067fb10 Mon Sep 17 00:00:00 2001 From: Daniel Țuțuianu Date: Sat, 23 May 2026 23:29:25 +0300 Subject: Refresh Live TV channel icons on every guide update. Guide refresh skipped channel logos once a primary image existed, so stale EPG/tuner icons never got replaced. --- src/Jellyfin.LiveTv/Channels/ChannelManager.cs | 4 +- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 14 ++---- src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs | 33 ++++++++++++++ .../LiveTvChannelImageHelperTests.cs | 51 ++++++++++++++++++++++ 4 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs (limited to 'tests') diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index ed02fe6a1d..e421601092 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; +using Jellyfin.LiveTv; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -1109,9 +1110,8 @@ namespace Jellyfin.LiveTv.Channels item.Path = mediaSource?.Path; } - if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary)) + if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, null, info.ImageUrl)) { - item.SetImagePath(ImageType.Primary, info.ImageUrl); _logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name); forceUpdate = true; } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 556516674b..b8545cbb64 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using Jellyfin.LiveTv; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Dto; @@ -448,18 +449,9 @@ public class GuideManager : IGuideManager item.Name = channelInfo.Name; - if (!item.HasImage(ImageType.Primary)) + if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl)) { - if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); - forceUpdate = true; - } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); - forceUpdate = true; - } + forceUpdate = true; } if (isNew) diff --git a/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs new file mode 100644 index 0000000000..a590193b5f --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs @@ -0,0 +1,33 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.LiveTv; + +/// +/// Helpers for keeping Live TV channel icons in sync with guide data. +/// +internal static class LiveTvChannelImageHelper +{ + /// + /// Applies the channel icon from guide or tuner metadata. + /// Called on each guide refresh so remote icons are re-downloaded even when the URL is unchanged. + /// + /// The channel item. + /// The local image path from the tuner, if any. + /// The remote image URL from the guide provider, if any. + /// true when the item image metadata was updated. + internal static bool UpdateChannelImageIfNeeded(BaseItem item, string? imagePath, string? imageUrl) + { + var newImageSource = !string.IsNullOrWhiteSpace(imagePath) + ? imagePath + : imageUrl; + + if (string.IsNullOrWhiteSpace(newImageSource)) + { + return false; + } + + item.SetImagePath(ImageType.Primary, newImageSource); + return true; + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs new file mode 100644 index 0000000000..f44cb88834 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs @@ -0,0 +1,51 @@ +using Jellyfin.LiveTv; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.LiveTv.Tests; + +public class LiveTvChannelImageHelperTests +{ + [Fact] + public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate() + { + var channel = new LiveTvChannel { Name = "Test Channel" }; + + var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null); + + Assert.False(updated); + Assert.False(channel.HasImage(ImageType.Primary)); + } + + [Fact] + public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl() + { + var channel = new LiveTvChannel { Name = "Test Channel" }; + + var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( + channel, + null, + "https://example.com/icon.png"); + + Assert.True(updated); + Assert.True(channel.HasImage(ImageType.Primary)); + Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); + } + + [Fact] + public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates() + { + var channel = new LiveTvChannel { Name = "Test Channel" }; + LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png"); + + var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( + channel, + null, + "https://example.com/icon.png"); + + Assert.True(updated); + Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); + } +} -- cgit v1.2.3 From e1a16b4ec64b0facf28b4cad9d6bf0808339461b Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Mon, 25 May 2026 11:35:38 -0400 Subject: Add XMLTV guide content ETags --- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 8 + .../Listings/XmlTvListingsProvider.cs | 24 ++- src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs | 184 +++++++++++++++++++++ .../Listings/XmlTvListingsProviderTests.cs | 57 +++++++ .../Listings/XmlTvProgramEtagTests.cs | 59 +++++++ .../Test Data/LiveTv/Listings/XmlTv/etag-base.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-category-change.xml | 17 ++ .../Listings/XmlTv/etag-description-change.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-icon-change.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-progid-change.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-reordered.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-title-change.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-unknown-field.xml | 18 ++ 13 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml (limited to 'tests') diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 556516674b..d59eb9c18f 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Listings; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -497,6 +498,13 @@ public class GuideManager : IGuideManager item.TrySetProviderId(EtagKey, info.Etag); } + else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey))) + { + // XMLTV ETags are generated from the final ProgramInfo fields Jellyfin consumes, + // so an exact match means nothing relevant changed. Other providers stay on the + // field-by-field update path. + return (item, false, false); + } if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index ec2e6cfcc9..622a1ac6fe 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -172,7 +172,29 @@ namespace Jellyfin.LiveTv.Listings var reader = new XmlTvReader(path, GetLanguage(info)); return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken) - .Select(p => GetProgramInfo(p, info)); + .Select(p => GetProgramInfoWithEtag(p, info)); + } + + private ProgramInfo GetProgramInfoWithEtag(XmlTvProgram program, ListingsProviderInfo info) + { + var programInfo = GetProgramInfo(program, info); + + if (XmlTvProgramEtag.TryCreate(programInfo, out var etag, out var reason)) + { + programInfo.Etag = etag; + } + else + { + _logger.LogDebug( + "Unable to create XMLTV program ETag for program {ProgramId} on channel {ChannelId} from {StartDate} to {EndDate}: {Reason}. The program will be treated as updated on each guide refresh.", + programInfo.Id, + programInfo.ChannelId, + programInfo.StartDate, + programInfo.EndDate, + reason); + } + + return programInfo; } private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs new file mode 100644 index 0000000000..b128b0ff9c --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs @@ -0,0 +1,184 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.Listings +{ + internal static class XmlTvProgramEtag + { + internal const string Prefix = "xmltv-sha256-v1:"; + + internal static bool IsXmlTvEtag(string? etag) + => !string.IsNullOrWhiteSpace(etag) + && etag.StartsWith(Prefix, StringComparison.Ordinal); + + // Returns true only when the incoming etag is XMLTV-style AND equals the stored value. + // The IsXmlTvEtag gate keeps other providers (e.g. Schedules Direct) on the + // field-by-field update path even if their etag strings happen to match. + internal static bool MatchesStored(string? incomingEtag, string? storedEtag) + => IsXmlTvEtag(incomingEtag) + && string.Equals(incomingEtag, storedEtag, StringComparison.OrdinalIgnoreCase); + + internal static bool TryCreate(ProgramInfo programInfo, out string? etag, out string? reason) + { + etag = null; + + if (string.IsNullOrWhiteSpace(programInfo.Id)) + { + reason = "program id is empty"; + return false; + } + + if (string.IsNullOrWhiteSpace(programInfo.ChannelId)) + { + reason = "channel id is empty"; + return false; + } + + if (programInfo.StartDate == default) + { + reason = "start date is empty"; + return false; + } + + if (programInfo.EndDate == default) + { + reason = "end date is empty"; + return false; + } + + if (programInfo.EndDate <= programInfo.StartDate) + { + reason = "end date is not after start date"; + return false; + } + + var builder = new StringBuilder(1024); + + // Keep this list aligned with the ProgramInfo fields consumed by GuideManager. + AppendValue(builder, "schema", "xmltv-programinfo-v1"); + AppendValue(builder, nameof(programInfo.Id), programInfo.Id); + AppendValue(builder, nameof(programInfo.ChannelId), programInfo.ChannelId); + AppendValue(builder, nameof(programInfo.Name), programInfo.Name); + AppendValue(builder, nameof(programInfo.OfficialRating), programInfo.OfficialRating); + AppendValue(builder, nameof(programInfo.Overview), programInfo.Overview); + AppendValue(builder, nameof(programInfo.StartDate), programInfo.StartDate); + AppendValue(builder, nameof(programInfo.EndDate), programInfo.EndDate); + AppendList(builder, nameof(programInfo.Genres), programInfo.Genres); + AppendValue(builder, nameof(programInfo.OriginalAirDate), programInfo.OriginalAirDate); + AppendValue(builder, nameof(programInfo.IsHD), programInfo.IsHD); + AppendValue(builder, nameof(programInfo.Audio), programInfo.Audio?.ToString()); + AppendValue(builder, nameof(programInfo.CommunityRating), programInfo.CommunityRating); + AppendValue(builder, nameof(programInfo.IsRepeat), programInfo.IsRepeat); + AppendValue(builder, nameof(programInfo.EpisodeTitle), programInfo.EpisodeTitle); + AppendValue(builder, nameof(programInfo.ImagePath), programInfo.ImagePath); + AppendValue(builder, nameof(programInfo.ImageUrl), programInfo.ImageUrl); + AppendValue(builder, nameof(programInfo.ThumbImageUrl), programInfo.ThumbImageUrl); + AppendValue(builder, nameof(programInfo.LogoImageUrl), programInfo.LogoImageUrl); + AppendValue(builder, nameof(programInfo.BackdropImageUrl), programInfo.BackdropImageUrl); + AppendValue(builder, nameof(programInfo.IsMovie), programInfo.IsMovie); + AppendValue(builder, nameof(programInfo.IsSports), programInfo.IsSports); + AppendValue(builder, nameof(programInfo.IsSeries), programInfo.IsSeries); + AppendValue(builder, nameof(programInfo.IsLive), programInfo.IsLive); + AppendValue(builder, nameof(programInfo.IsNews), programInfo.IsNews); + AppendValue(builder, nameof(programInfo.IsKids), programInfo.IsKids); + AppendValue(builder, nameof(programInfo.IsPremiere), programInfo.IsPremiere); + AppendValue(builder, nameof(programInfo.ProductionYear), programInfo.ProductionYear); + AppendValue(builder, nameof(programInfo.SeriesId), programInfo.SeriesId); + AppendValue(builder, nameof(programInfo.ShowId), programInfo.ShowId); + AppendValue(builder, nameof(programInfo.SeasonNumber), programInfo.SeasonNumber); + AppendValue(builder, nameof(programInfo.EpisodeNumber), programInfo.EpisodeNumber); + AppendDictionary(builder, nameof(programInfo.ProviderIds), programInfo.ProviderIds); + AppendDictionary(builder, nameof(programInfo.SeriesProviderIds), programInfo.SeriesProviderIds); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + etag = Prefix + Convert.ToHexString(hash); + reason = null; + return true; + } + + private static void AppendValue(StringBuilder builder, string name, string? value) + { + builder.Append(name).Append('|'); + if (value is null) + { + builder.Append('N').Append("|0|"); + } + else + { + builder.Append('S') + .Append('|') + .Append(value.Length.ToString(CultureInfo.InvariantCulture)) + .Append('|') + .Append(value); + } + + builder.Append('\n'); + } + + private static void AppendValue(StringBuilder builder, string name, DateTime value) + => AppendValue(builder, name, FormatDateTime(value)); + + private static void AppendValue(StringBuilder builder, string name, DateTime? value) + => AppendValue(builder, name, value.HasValue ? FormatDateTime(value.Value) : null); + + // Treat Unspecified as UTC so the etag does not vary with the server's local timezone. + private static string FormatDateTime(DateTime value) + { + var utc = value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc), + _ => value.ToUniversalTime(), + }; + + return utc.ToString("O", CultureInfo.InvariantCulture); + } + + private static void AppendValue(StringBuilder builder, string name, bool value) + => AppendValue(builder, name, value ? "true" : "false"); + + private static void AppendValue(StringBuilder builder, string name, bool? value) + => AppendValue(builder, name, value switch { true => "true", false => "false", null => null }); + + private static void AppendValue(StringBuilder builder, string name, int? value) + => AppendValue(builder, name, value?.ToString(CultureInfo.InvariantCulture)); + + private static void AppendValue(StringBuilder builder, string name, float? value) + => AppendValue(builder, name, value?.ToString("R", CultureInfo.InvariantCulture)); + + private static void AppendList(StringBuilder builder, string name, IReadOnlyList values) + { + AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture)); + for (var i = 0; i < values.Count; i++) + { + AppendValue(builder, $"{name}[{i}]", values[i]); + } + } + + private static void AppendDictionary(StringBuilder builder, string name, IReadOnlyDictionary values) + { + AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture)); + if (values.Count == 0) + { + return; + } + + var index = 0; + foreach (var (key, value) in values + .OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Key, StringComparer.Ordinal)) + { + AppendValue(builder, $"{name}[{index}].Key", key); + AppendValue(builder, $"{name}[{index}].Value", value); + index++; + } + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs index b71dc15201..f698edc637 100644 --- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.LiveTv; using Moq; using Moq.Protected; @@ -66,6 +67,7 @@ public class XmlTvListingsProviderTests Assert.True(program.HasImage); Assert.Equal("https://domain.tld/image.png", program.ImageUrl); Assert.Equal("3297", program.ChannelId); + AssertXmlTvEtag(program.Etag); } [Theory] @@ -85,5 +87,60 @@ public class XmlTvListingsProviderTests var program = programsList[0]; Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); Assert.Equal("3297", program.ChannelId); + AssertXmlTvEtag(program.Etag); + } + + [Fact] + public async Task GetProgramsAsync_Etag_SameContentIsStable() + { + var first = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var second = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + + Assert.Equal(first.Etag, second.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml")] + public async Task GetProgramsAsync_Etag_ChangesWhenMappedContentChanges(string changedPath) + { + var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var changed = await GetSingleProgramAsync(changedPath); + + Assert.NotEqual(original.Etag, changed.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml")] + public async Task GetProgramsAsync_Etag_DoesNotChangeWhenMappedContentIsEquivalent(string equivalentPath) + { + var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var equivalent = await GetSingleProgramAsync(equivalentPath); + + Assert.Equal(original.Etag, equivalent.Etag); + } + + private async Task GetSingleProgramAsync(string path) + { + var info = new ListingsProviderInfo() + { + Id = Path.GetFileNameWithoutExtension(path), + Path = path + }; + + var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + + return Assert.Single(programs.ToList()); + } + + private static void AssertXmlTvEtag(string? etag) + { + Assert.NotNull(etag); + Assert.StartsWith("xmltv-sha256-v1:", etag!, StringComparison.Ordinal); } } diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs new file mode 100644 index 0000000000..b8d1c60e1a --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs @@ -0,0 +1,59 @@ +using System; +using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller.LiveTv; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public class XmlTvProgramEtagTests +{ + [Fact] + public void TryCreate_GenreOrderIsSignificant() + { + // GuideManager assigns item.Genres = info.Genres.ToArray() preserving order, + // so the same genres in a different order is a real mapped-content change. + var first = NewProgram(); + first.Genres = new() { "Drama", "Action" }; + + var second = NewProgram(); + second.Genres = new() { "Action", "Drama" }; + + Assert.True(XmlTvProgramEtag.TryCreate(first, out var firstEtag, out _)); + Assert.True(XmlTvProgramEtag.TryCreate(second, out var secondEtag, out _)); + Assert.NotEqual(firstEtag, secondEtag); + } + + [Fact] + public void MatchesStored_EqualXmlTvEtags_ReturnsTrue() + { + const string Etag = XmlTvProgramEtag.Prefix + "ABCDEF0123456789"; + Assert.True(XmlTvProgramEtag.MatchesStored(Etag, Etag)); + } + + [Fact] + public void MatchesStored_DifferentXmlTvEtags_ReturnsFalse() + { + Assert.False(XmlTvProgramEtag.MatchesStored( + XmlTvProgramEtag.Prefix + "AAAA", + XmlTvProgramEtag.Prefix + "BBBB")); + } + + [Fact] + public void MatchesStored_EqualNonXmlTvEtags_ReturnsFalse() + { + // Other providers (e.g. Schedules Direct) use their own etag schemes. + // The IsXmlTvEtag gate must keep them on the field-by-field update path + // even when their incoming and stored values happen to match exactly. + const string Etag = "sd-abc123"; + Assert.False(XmlTvProgramEtag.MatchesStored(Etag, Etag)); + } + + private static ProgramInfo NewProgram() => new() + { + Id = "program-id", + ChannelId = "channel-id", + Name = "Program Name", + StartDate = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + EndDate = new DateTime(2026, 1, 1, 13, 0, 0, DateTimeKind.Utc), + }; +} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml new file mode 100644 index 0000000000..15f85f57e6 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml new file mode 100644 index 0000000000..2b49c3bccd --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Base description. + sports + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml new file mode 100644 index 0000000000..090273ac98 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Changed description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml new file mode 100644 index 0000000000..532b91da20 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml new file mode 100644 index 0000000000..db0d5e86de --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789013 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml new file mode 100644 index 0000000000..168c0a643b --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml @@ -0,0 +1,17 @@ + + + + + 3/5 + + + TV-G + + 0 . 1 . + EP123456789012 + series + Base description. + Base Episode + Base Program + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml new file mode 100644 index 0000000000..73288e7c57 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml @@ -0,0 +1,17 @@ + + + Changed Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml new file mode 100644 index 0000000000..d0ff1b82f5 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml @@ -0,0 +1,18 @@ + + + Base Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + Ignored by Jellyfin XMLTV mapping. + + + -- cgit v1.2.3 From 5104497331c0519c551e1af6b3999f0da0d65058 Mon Sep 17 00:00:00 2001 From: David Federman Date: Tue, 2 Jun 2026 23:12:50 -0700 Subject: Reject unsafe plugin package names in installer --- .../Updates/InstallationManager.cs | 43 ++++++++++++++++++++++ .../Updates/InstallationManagerTests.cs | 24 ++++++++++++ 2 files changed, 67 insertions(+) (limited to 'tests') diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index ef53e3b326..c8a2d98bf4 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -521,9 +521,27 @@ namespace Emby.Server.Implementations.Updates return; } + if (!IsValidPackageDirectoryName(package.Name)) + { + _logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name); + throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name."); + } + // Always override the passed-in target (which is a file) and figure it out again string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); + var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath)); + var resolvedTarget = Path.GetFullPath(targetDir); + if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.", + package.Name, + resolvedTarget, + pluginsRoot); + throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory."); + } + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); @@ -572,6 +590,31 @@ namespace Emby.Server.Implementations.Updates _pluginManager.ImportPluginFrom(targetDir); } + private static bool IsValidPackageDirectoryName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal)) + { + return false; + } + + if (name.Contains('/', StringComparison.Ordinal) || name.Contains('\\', StringComparison.Ordinal)) + { + return false; + } + + if (name.AsSpan().IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + return false; + } + + return true; + } + private async Task InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) { LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version)) diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs index 92e10c9f92..4a10b2f607 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); Assert.Null(ex); } + + [Theory] + [InlineData("../evil")] + [InlineData("..\\evil")] + [InlineData("../../escape_attempt")] + [InlineData("..")] + [InlineData(".")] + [InlineData("")] + [InlineData(" ")] + [InlineData("foo/bar")] + [InlineData("foo\\bar")] + [InlineData("/absolute")] + [InlineData("foo\0bar")] + public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name) + { + var packageInfo = new InstallationInfo() + { + Name = name, + SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip", + Checksum = "11b5b2f1a9ebc4f66d6ef19018543361" + }; + + await Assert.ThrowsAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); + } } } -- cgit v1.2.3 From 507998a4e327ad13d03b4244da967cffb8b03a72 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 7 Jun 2026 22:37:34 +0200 Subject: Derive version-aware media source names --- MediaBrowser.Controller/Entities/BaseItem.cs | 117 +++++++++++++++++++-- .../Entities/BaseItemTests.cs | 62 +++++++++++ 2 files changed, 172 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 21304768bd..c69e24f876 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -87,6 +87,10 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; + // Separators the naming layer treats as version delimiters (Emby.Naming VideoFlagDelimiters), + // used when stripping the shared prefix from an alternate version's media source name. + private static readonly char[] VersionSeparators = [' ', '-', '_', '.']; + private string _sortName; private string _forcedSortName; @@ -1099,8 +1103,9 @@ namespace MediaBrowser.Controller.Entities } } - var list = GetAllItemsForMediaSources(); - var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList(); + var list = GetAllItemsForMediaSources().ToList(); + var commonPrefix = GetCommonNamePrefix(list); + var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList(); if (IsActiveRecording()) { @@ -1128,7 +1133,7 @@ namespace MediaBrowser.Controller.Entities return Enumerable.Empty<(BaseItem, MediaSourceType)>(); } - private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type) + private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null) { ArgumentNullException.ThrowIfNull(item); @@ -1141,7 +1146,7 @@ namespace MediaBrowser.Controller.Entities Protocol = protocol ?? MediaProtocol.File, MediaStreams = MediaSourceManager.GetMediaStreams(item.Id), MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id), - Name = GetMediaSourceName(item), + Name = GetMediaSourceName(item, commonPrefix), Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath, RunTimeTicks = item.RunTimeTicks, Container = item.Container, @@ -1220,7 +1225,7 @@ namespace MediaBrowser.Controller.Entities return info; } - internal string GetMediaSourceName(BaseItem item) + internal string GetMediaSourceName(BaseItem item, string commonPrefix = null) { var terms = new List(); @@ -1228,12 +1233,31 @@ namespace MediaBrowser.Controller.Entities if (item.IsFileProtocol && !string.IsNullOrEmpty(path)) { var displayName = System.IO.Path.GetFileNameWithoutExtension(path); - if (HasLocalAlternateVersions) + + // Prefer the suffix that differs from the other versions: strip the prefix shared by + // all sibling files. This works regardless of folder layout, so it also labels episode + // versions that share a season folder (e.g. "Greyscale" instead of the full + // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a separator + // boundary (see GetCommonVersionPrefix). + if (!string.IsNullOrEmpty(commonPrefix) + && displayName.Length > commonPrefix.Length + && displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase)) + { + var name = displayName.AsSpan(commonPrefix.Length).TrimStart(VersionSeparators); + if (!name.IsWhiteSpace()) + { + terms.Add(name.ToString()); + } + } + + // Fall back to the containing folder name (the common layout for movie versions, and + // the path taken when no common prefix could be derived). + if (terms.Count == 0 && HasLocalAlternateVersions) { var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath); if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase)) { - var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']); + var name = displayName.AsSpan(containingFolderName.Length).TrimStart(VersionSeparators); if (!name.IsWhiteSpace()) { terms.Add(name.ToString()); @@ -1290,6 +1314,85 @@ namespace MediaBrowser.Controller.Entities return string.Join('/', terms); } + /// + /// Derives the prefix shared by the supplied media source items' file names, used to strip the + /// common part and surface a short version label per source. Returns null when there are fewer + /// than two file-based sources, since there is nothing to differentiate. + /// + /// The media source items. + /// The shared prefix, or null when no useful prefix exists. + private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items) + { + var fileNames = new List(); + foreach (var (item, _) in items) + { + if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path)) + { + fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path)); + } + } + + if (fileNames.Count < 2) + { + return null; + } + + var prefix = GetCommonVersionPrefix(fileNames); + return string.IsNullOrEmpty(prefix) ? null : prefix; + } + + /// + /// Computes the case-insensitive longest common prefix of the supplied version file names, + /// retreated to the last separator boundary. Retreating keeps the differing suffix intact: + /// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and + /// "Greyish") while still trimming the common part when every version is suffixed (e.g. + /// "- Greyscale" / "- Colorized"). The separators mirror the version delimiters recognised by + /// the naming layer (Emby.Naming VideoFlagDelimiters). + /// + /// The version file names without extension; must contain at least one entry. + /// The shared prefix retreated to a separator boundary, or an empty string when none is shared. + internal static string GetCommonVersionPrefix(IReadOnlyList fileNames) + { + var prefix = fileNames[0]; + for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++) + { + var name = fileNames[i]; + var length = Math.Min(prefix.Length, name.Length); + var common = 0; + while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common])) + { + common++; + } + + prefix = prefix[..common]; + } + + // If the common prefix is itself a whole file name then one version is unlabelled (the + // base name); the boundary already sits at the end of that name, so don't retreat into it. + var prefixIsWholeName = false; + for (var i = 0; i < fileNames.Count; i++) + { + if (fileNames[i].Length == prefix.Length) + { + prefixIsWholeName = true; + break; + } + } + + if (!prefixIsWholeName) + { + var cut = prefix.Length; + while (cut > 0 && Array.IndexOf(VersionSeparators, prefix[cut - 1]) < 0) + { + cut--; + } + + prefix = prefix[..cut]; + } + + return prefix; + } + public Task RefreshMetadata(CancellationToken cancellationToken) { return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken); diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index 5c187da413..8af176138c 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -46,4 +46,66 @@ public class BaseItemTests Assert.Equal(name, video.GetMediaSourceName(video)); Assert.Equal(altName, video.GetMediaSourceName(videoAlt)); } + + [Theory] + // Episode versions share a season folder; the common prefix (not the folder name) yields the label. + // Both files carry a suffix (no bare base name), so the shared "- " must be stripped too. + [InlineData( + "Spider-Noir - S01E02 - Wo ist Flint - Greyscale", + "Spider-Noir - S01E02 - Wo ist Flint - Colorized", + "Greyscale", + "Colorized")] + // One version is the bare base name; the other is suffixed. + [InlineData( + "Spider-Noir - S01E02 - Wo ist Flint", + "Spider-Noir - S01E02 - Wo ist Flint - Greyscale", + "Spider-Noir - S01E02 - Wo ist Flint", + "Greyscale")] + // Suffixes share a leading word ("Grey"); the prefix must retreat to the separator, not split it. + [InlineData( + "Demo - S01E01 - Greyscale", + "Demo - S01E01 - Greyish", + "Greyscale", + "Greyish")] + // Underscore separator. + [InlineData("Movie (2020)_4K", "Movie (2020)_1080p", "4K", "1080p")] + // Dot separator. + [InlineData("Movie (2020).UHD", "Movie (2020).1080p", "UHD", "1080p")] + // Resolution variants that share leading digits must retreat to the separator, not yield "p"/"i". + [InlineData("Movie - 1080p", "Movie - 1080i", "1080p", "1080i")] + // Bracketed version labels: the opening bracket is kept in the label. + [InlineData( + "Blade Runner (1982) [Final Cut] [1080p HEVC AAC]", + "Blade Runner (1982) [EE by ADM] [480p HEVC AAC]", + "[Final Cut] [1080p HEVC AAC]", + "[EE by ADM] [480p HEVC AAC]")] + public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt) + { + var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv"; + var altPath = "/Shows/Demo/Season 01/" + altName + ".mkv"; + var commonPrefix = BaseItem.GetCommonVersionPrefix([primaryName, altName]); + + var video = new Video() + { + Path = primaryPath + }; + + var videoAlt = new Video() + { + Path = altPath, + }; + + var mediaSourceManager = new Mock(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny())) + .Returns((string x) => MediaProtocol.File); + var libraryManager = new Mock(); + // No local alternate versions: these are linked (separate items), so the folder fallback is unavailable. + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny