aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorWizardOfYendor1 <WizardOfYendor1@users.noreply.github.com>2026-05-25 11:35:38 -0400
committerWizardOfYendor1 <WizardOfYendor1@users.noreply.github.com>2026-05-25 20:04:21 -0400
commite1a16b4ec64b0facf28b4cad9d6bf0808339461b (patch)
treeb89ad9df4b20c3e3fcdb3224666f092e67056b1e
parent498d265208673c323cc8401b45e0f17382fde7ae (diff)
Add XMLTV guide content ETags
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs8
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs24
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs184
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs57
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs59
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml18
13 files changed, 468 insertions, 1 deletions
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<string> 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<string, string?> 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<ProgramInfo> 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 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
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 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">sports</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
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 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Changed description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
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 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/changed.png"/>
+ </programme>
+</tv>
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 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789013</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
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 @@
+<tv date="20221104">
+ <programme channel="3297" stop="20221104140000 +0000" start="20221104130000 +0000">
+ <icon src="https://domain.tld/base.png"/>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <category lang="en">series</category>
+ <desc lang="en">Base description.</desc>
+ <sub-title lang="en">Base Episode</sub-title>
+ <title lang="en">Base Program</title>
+ </programme>
+</tv>
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 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Changed Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
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 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <previously-unknown-field>Ignored by Jellyfin XMLTV mapping.</previously-unknown-field>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>