aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Shadowghost@users.noreply.github.com>2026-09-15 11:16:03 -0400
committerCody Robibero <cody@robibe.ro>2026-09-15 11:16:03 -0400
commitfcd010d30812664e5b3df539fc30b10d8b23a96a (patch)
tree828d8b2b741ee1e6e762d26d766715ddf1655656
parent394facc327a2aeff139279ff35432bcf4755ff36 (diff)
Backport pull request #17935 from jellyfin/release-12.z
Fix EPG issues Original-merge: a423a2eaba674f7ce36a8f8c8da7b4d6b75f9f39 Merged-by: crobibero <cody@robibe.ro> Backported-by: Cody Robibero <cody@robibe.ro>
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs24
-rw-r--r--src/Jellyfin.LiveTv/Listings/ListingsManager.cs7
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs66
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs102
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs130
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs137
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs43
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml10
8 files changed, 463 insertions, 56 deletions
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index 41520f8789..a11f83f2f8 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -125,12 +125,16 @@ public class GuideManager : IGuideManager
{
var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService));
- var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false);
+ var (channelIds, programIds, hasErrors) = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false);
- newChannelIdList.AddRange(idList.Item1);
- newProgramIdList.AddRange(idList.Item2);
+ newChannelIdList.AddRange(channelIds);
+ newProgramIdList.AddRange(programIds);
+
+ // The channels that failed did not report any programs, so cleaning the database
+ // would delete every program they provide instead of keeping the previous ones.
+ cleanDatabase &= !hasErrors;
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
@@ -172,10 +176,12 @@ public class GuideManager : IGuideManager
: 7;
}
- private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken)
+ private async Task<(List<Guid> ChannelIds, List<Guid> ProgramIds, bool HasErrors)> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken)
{
progress.Report(10);
+ var hasErrors = false;
+
var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
.Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
.ToList();
@@ -195,12 +201,13 @@ public class GuideManager : IGuideManager
list.Add(item);
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
+ hasErrors = true;
_logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
}
@@ -314,12 +321,13 @@ public class GuideManager : IGuideManager
},
cancellationToken).ConfigureAwait(false);
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
+ hasErrors = true;
_logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
}
@@ -330,7 +338,7 @@ public class GuideManager : IGuideManager
}
progress.Report(100);
- return new Tuple<List<Guid>, List<Guid>>(channels, programIds);
+ return (channels, programIds, hasErrors);
}
private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken)
diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
index 15e20d6f64..7274a3030c 100644
--- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
+++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
@@ -352,9 +352,12 @@ public class ListingsManager : IListingsManager
var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml");
try
{
- File.Delete(xmltvCacheFile);
+ if (File.Exists(xmltvCacheFile))
+ {
+ File.Delete(xmltvCacheFile);
+ }
}
- catch (IOException ex)
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", safeId);
}
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
index c93d1f039c..40ec99e9a5 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
@@ -549,46 +549,52 @@ namespace Jellyfin.LiveTv.Listings
{
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
- var lineups = new List<NameIdPair>();
-
if (string.IsNullOrWhiteSpace(token))
{
- return lineups;
+ throw new AuthenticationException("Could not authenticate with Schedules Direct");
}
+ var lineups = new List<NameIdPair>();
+
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location);
options.Headers.TryAddWithoutValidation("token", token);
- try
+ var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false);
+ foreach (HeadendsDto headend in root ?? [])
{
- var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false);
- if (root is not null)
+ foreach (LineupDto lineup in headend.Lineups ?? [])
{
- foreach (HeadendsDto headend in root)
+ lineups.Add(new NameIdPair
{
- foreach (LineupDto lineup in headend.Lineups)
- {
- lineups.Add(new NameIdPair
- {
- Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name,
- Id = lineup.Uri?[18..]
- });
- }
- }
- }
- else
- {
- _logger.LogInformation("No lineups available");
+ Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name,
+ Id = string.IsNullOrWhiteSpace(lineup.Lineup) ? lineup.Uri?.Split('/')[^1] : lineup.Lineup
+ });
}
}
- catch (Exception ex)
+
+ if (lineups.Count == 0)
{
- _logger.LogError(ex, "Error getting headends");
+ _logger.LogWarning(
+ "Schedules Direct has no lineups for country {Country} and postal code {PostalCode}",
+ country,
+ location);
}
return lineups;
}
+ private void ResetErrorState(ListingsProviderInfo info)
+ {
+ _accountError = false;
+ Interlocked.Exchange(ref _lastErrorResponseTicks, 0);
+
+ // Only the account being saved is retried, the tokens of the other accounts stay valid.
+ if (!string.IsNullOrWhiteSpace(info.Username))
+ {
+ _tokens.TryRemove(info.Username, out _);
+ }
+ }
+
private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
{
var username = info.Username;
@@ -605,15 +611,19 @@ namespace Jellyfin.LiveTv.Listings
return null;
}
- // Permanent account error — SD is disabled for this server lifetime.
+ // Account error — SD stays disabled until the provider is saved again or the server restarts.
if (_accountError)
{
+ _logger.LogWarning("Skipping Schedules Direct request because of an earlier account error. Save the listings provider again to retry.");
+
return null;
}
// Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout)
if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30)
{
+ _logger.LogWarning("Skipping Schedules Direct request because of a recent login failure. Retrying no earlier than 30 minutes after it.");
+
return null;
}
@@ -776,7 +786,7 @@ namespace Jellyfin.LiveTv.Listings
return root.Token;
}
- throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message);
+ throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + (root?.Message ?? "empty response"));
}
private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken)
@@ -992,10 +1002,18 @@ namespace Jellyfin.LiveTv.Listings
public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
{
+ ResetErrorState(info);
+
if (validateLogin)
{
ArgumentException.ThrowIfNullOrEmpty(info.Username);
ArgumentException.ThrowIfNullOrEmpty(info.Password);
+
+ var token = await GetToken(info, CancellationToken.None).ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new AuthenticationException("Could not authenticate with Schedules Direct");
+ }
}
if (validateListings)
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
index 0aeb7ad05d..f78d464659 100644
--- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@@ -27,11 +28,14 @@ namespace Jellyfin.LiveTv.Listings
public class XmlTvListingsProvider : IListingsProvider
{
private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1);
+ private static readonly TimeSpan _downloadTimeout = TimeSpan.FromMinutes(15);
private readonly IServerConfigurationManager _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<XmlTvListingsProvider> _logger;
+ private readonly ConcurrentDictionary<string, DateTime> _lastDownloadFailures = new(StringComparer.Ordinal);
+
public XmlTvListingsProvider(
IServerConfigurationManager config,
IHttpClientFactory httpClientFactory,
@@ -64,32 +68,51 @@ namespace Jellyfin.LiveTv.Listings
string cacheDir = Path.Join(_config.ApplicationPaths.CachePath, "xmltv");
string cacheFile = Path.Join(cacheDir, cacheFilename);
- if (File.Exists(cacheFile))
+ if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge))
+ {
+ return cacheFile;
+ }
+
+ var isRemote = info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase);
+
+ if (isRemote
+ && _lastDownloadFailures.TryGetValue(info.Path, out var lastFailure)
+ && DateTime.UtcNow - lastFailure < _maxCacheAge)
{
- if (File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge))
+ if (File.Exists(cacheFile))
{
return cacheFile;
}
- File.Delete(cacheFile);
- }
- else
- {
- Directory.CreateDirectory(cacheDir);
+ throw new InvalidOperationException("Skipping the XMLTV download after a recent failure: " + info.Path);
}
+ Directory.CreateDirectory(cacheDir);
+
+ var tempFile = cacheFile + ".tmp";
+
try
{
- if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ using var timeout = new CancellationTokenSource(_downloadTimeout);
+ using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
+ var downloadCancellationToken = linkedTokenSource.Token;
+
+ if (isRemote)
{
_logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
- using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false);
+ var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
+ httpClient.Timeout = _downloadTimeout;
+
+ using var response = await httpClient
+ .GetAsync(info.Path, HttpCompletionOption.ResponseHeadersRead, downloadCancellationToken)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
- var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ var stream = await response.Content.ReadAsStreamAsync(downloadCancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
- return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
+ await UnzipIfNeededAndCopy(redirectedUrl, stream, tempFile, downloadCancellationToken).ConfigureAwait(false);
}
}
else
@@ -97,28 +120,63 @@ namespace Jellyfin.LiveTv.Listings
var stream = AsyncFile.OpenRead(info.Path);
await using (stream.ConfigureAwait(false))
{
- return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
+ await UnzipIfNeededAndCopy(info.Path, stream, tempFile, downloadCancellationToken).ConfigureAwait(false);
}
}
+
+ File.Move(tempFile, cacheFile, true);
+ _lastDownloadFailures.TryRemove(info.Path, out _);
+
+ return cacheFile;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ TryDeleteTempFile(tempFile);
+
+ throw;
}
catch (Exception ex)
{
+ TryDeleteTempFile(tempFile);
+ _lastDownloadFailures[info.Path] = DateTime.UtcNow;
+
_logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path);
if (File.Exists(cacheFile))
{
- File.Delete(cacheFile);
+ _logger.LogWarning("Falling back to the previously downloaded XMLTV file for {Path}", info.Path);
+
+ return cacheFile;
+ }
+
+ if (ex is OperationCanceledException)
+ {
+ throw new TimeoutException(
+ string.Format(CultureInfo.InvariantCulture, "Timed out downloading the XMLTV file from {0}", info.Path),
+ ex);
}
throw;
}
}
- private async Task<string> UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken)
+ private void TryDeleteTempFile(string tempFile)
+ {
+ try
+ {
+ File.Delete(tempFile);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogWarning(ex, "Error deleting temporary XMLTV file {File}", tempFile);
+ }
+ }
+
+ private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken)
{
var fileStream = new FileStream(
file,
- FileMode.CreateNew,
+ FileMode.Create,
FileAccess.Write,
FileShare.None,
IODefaults.FileStreamBufferSize,
@@ -148,15 +206,8 @@ namespace Jellyfin.LiveTv.Listings
var fileInfo = new FileInfo(file);
if (!fileInfo.Exists || fileInfo.Length == 0)
{
- if (fileInfo.Exists)
- {
- File.Delete(file);
- }
-
throw new InvalidOperationException("Downloaded XMLTV file is empty: " + originalUrl);
}
-
- return file;
}
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
@@ -281,6 +332,13 @@ namespace Jellyfin.LiveTv.Listings
public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
{
+ // Saving the provider is an explicit retry, so the download backoff has to be dropped
+ // together with the cached file the listings manager deletes.
+ if (!string.IsNullOrEmpty(info.Path))
+ {
+ _lastDownloadFailures.TryRemove(info.Path, out _);
+ }
+
// Assume all urls are valid. check files for existence
if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path))
{
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs
new file mode 100644
index 0000000000..04c7b05c3d
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs
@@ -0,0 +1,130 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Moq.Protected;
+using Xunit;
+using SchedulesDirectProvider = Jellyfin.LiveTv.Listings.SchedulesDirect;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public class SchedulesDirectLineupTests
+{
+ private const string InvalidUserResponse = "{\"response\":\"INVALID_USER\",\"code\":4003,\"message\":\"Invalid user.\",\"serverID\":\"AWS-SD-web.1\"}";
+
+ private static readonly ListingsProviderInfo _info = new() { Username = "user", Password = "password" };
+
+ [Fact]
+ public async Task GetLineups_ValidCredentials_ReturnsLineups()
+ {
+ var tokenResponse = await CreateSuccessfulLogin();
+ using var provider = CreateProvider(tokenResponse, await GetHeadendsResponse());
+
+ var lineups = await provider.GetLineups(_info, "USA", "90210");
+
+ Assert.NotEmpty(lineups);
+ Assert.Contains(lineups, i => string.Equals(i.Id, "USA-OTA-90210", StringComparison.Ordinal));
+ Assert.Contains(lineups, i => string.Equals(i.Name, "Antenna", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task GetLineups_LoginFails_Throws()
+ {
+ using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse());
+
+ // An empty lineup list is indistinguishable from "no lineups for this location", so a
+ // failed login has to surface as an error instead.
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210"));
+ }
+
+ [Fact]
+ public async Task Validate_LoginFails_Throws()
+ {
+ using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse());
+
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.Validate(_info, true, false));
+ }
+
+ [Fact]
+ public async Task Validate_AfterAccountError_RecoversWithoutRestart()
+ {
+ var login = CreateFailedLogin();
+ using var provider = CreateProvider(login, await GetHeadendsResponse());
+
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210"));
+
+ // The account error disables Schedules Direct; saving the provider again is the user
+ // correcting their credentials, and that has to recover without a server restart.
+ login.Status = HttpStatusCode.OK;
+ login.Body = await GetTokenResponse();
+
+ await provider.Validate(_info, true, false);
+
+ Assert.NotEmpty(await provider.GetLineups(_info, "USA", "90210"));
+ }
+
+ private static async Task<Response> CreateSuccessfulLogin()
+ => new() { Status = HttpStatusCode.OK, Body = await GetTokenResponse() };
+
+ private static Response CreateFailedLogin()
+ => new() { Status = HttpStatusCode.BadRequest, Body = InvalidUserResponse };
+
+ private static Task<string> GetTokenResponse()
+ => File.ReadAllTextAsync("Test Data/SchedulesDirect/token_live_response.json", TestContext.Current.CancellationToken);
+
+ private static Task<string> GetHeadendsResponse()
+ => File.ReadAllTextAsync("Test Data/SchedulesDirect/headends_response.json", TestContext.Current.CancellationToken);
+
+ private static SchedulesDirectProvider CreateProvider(Response login, string headendsResponse)
+ {
+ var messageHandler = new Mock<HttpMessageHandler>();
+ messageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
+ .Returns<HttpRequestMessage, CancellationToken>((m, _) =>
+ {
+ var path = m.RequestUri!.AbsolutePath;
+ if (path.EndsWith("/token", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new HttpResponseMessage(login.Status)
+ {
+ Content = new StringContent(login.Body)
+ });
+ }
+
+ if (path.EndsWith("/headends", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(headendsResponse)
+ });
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
+ });
+
+ var httpClientFactory = new Mock<IHttpClientFactory>();
+ httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>()))
+ .Returns(() => new HttpClient(messageHandler.Object));
+
+ var appPaths = new Mock<IApplicationPaths>();
+ appPaths.SetupGet(x => x.CachePath).Returns(Path.GetTempPath());
+
+ return new SchedulesDirectProvider(
+ NullLogger<SchedulesDirectProvider>.Instance,
+ httpClientFactory.Object,
+ appPaths.Object);
+ }
+
+ private sealed class Response
+ {
+ public HttpStatusCode Status { get; set; }
+
+ public string Body { get; set; } = string.Empty;
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs
new file mode 100644
index 0000000000..1d96c5a958
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public sealed class XmlTvListingsProviderCacheTests : IDisposable
+{
+ private const string ChannelId = "3297";
+
+ private readonly string _cachePath = Path.Combine(Path.GetTempPath(), "jellyfin-xmltv-tests-" + Guid.NewGuid().ToString("N"));
+
+ private readonly ListingsProviderInfo _info = new()
+ {
+ Id = "cachetests",
+ Path = "https://example.com/notitle.xml"
+ };
+
+ private bool _downloadsFail;
+ private Exception? _downloadException;
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_cachePath))
+ {
+ Directory.Delete(_cachePath, true);
+ }
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_DownloadFailsAfterASuccess_KeepsUsingTheCachedListings()
+ {
+ var provider = CreateProvider();
+
+ Assert.NotEmpty(await GetPrograms(provider));
+
+ // Age the cached copy out, so the next call goes back to the (now broken) source.
+ var cacheFile = Path.Combine(_cachePath, "xmltv", _info.Id + ".xml");
+ File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddDays(-1));
+ _downloadsFail = true;
+
+ // Losing the listings entirely because a single download failed empties the whole guide.
+ Assert.NotEmpty(await GetPrograms(provider));
+ Assert.True(File.Exists(cacheFile));
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_DownloadTimesOut_DoesNotSurfaceAsCancellation()
+ {
+ var provider = CreateProvider();
+
+ // This is how HttpClient reports its own timeout. Left as an OperationCanceledException it
+ // aborts the guide refresh for every channel and provider instead of only this one.
+ _downloadsFail = true;
+ _downloadException = new TaskCanceledException("timeout", new TimeoutException());
+
+ await Assert.ThrowsAsync<TimeoutException>(() => GetPrograms(provider));
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_ProviderSavedAfterAFailure_DownloadsAgain()
+ {
+ var provider = CreateProvider();
+
+ _downloadsFail = true;
+ await Assert.ThrowsAnyAsync<Exception>(() => GetPrograms(provider));
+
+ // Without clearing the backoff the guide stays empty for an hour, even though saving the
+ // provider deletes the cached file and is the user asking for another attempt.
+ _downloadsFail = false;
+ await provider.Validate(_info, true, true);
+
+ Assert.NotEmpty(await GetPrograms(provider));
+ }
+
+ private async Task<ProgramInfo[]> GetPrograms(XmlTvListingsProvider provider)
+ {
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await provider.GetProgramsAsync(_info, ChannelId, startDate, startDate.AddDays(1), CancellationToken.None);
+
+ return programs.ToArray();
+ }
+
+ private XmlTvListingsProvider CreateProvider()
+ {
+ var messageHandler = new Mock<HttpMessageHandler>();
+ messageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
+ .Returns<HttpRequestMessage, CancellationToken>((m, _) =>
+ {
+ if (_downloadException is not null)
+ {
+ return Task.FromException<HttpResponseMessage>(_downloadException);
+ }
+
+ if (_downloadsFail)
+ {
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1])))
+ });
+ });
+
+ var httpClientFactory = new Mock<IHttpClientFactory>();
+ httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>()))
+ .Returns(() => new HttpClient(messageHandler.Object));
+
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.SetupGet(x => x.CachePath).Returns(_cachePath);
+
+ var config = new Mock<IServerConfigurationManager>();
+ config.SetupGet(x => x.ApplicationPaths).Returns(appPaths.Object);
+ config.SetupGet(x => x.Configuration).Returns(new ServerConfiguration());
+
+ return new XmlTvListingsProvider(
+ config.Object,
+ httpClientFactory.Object,
+ NullLogger<XmlTvListingsProvider>.Instance);
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
index f698edc637..a8ebc8c9b9 100644
--- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
@@ -90,6 +90,49 @@ public class XmlTvListingsProviderTests
AssertXmlTvEtag(program.Etag);
}
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")]
+ [InlineData("https://example.com/no-optional-elements.xml")]
+ public async Task GetProgramsAsync_NoOptionalElements_Success(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = "no-optional-elements-programs",
+ 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);
+ var program = Assert.Single(programs.ToList());
+ Assert.Equal("Programme Without Icon Or Rating", program.Name);
+ Assert.False(program.HasImage);
+ Assert.Null(program.ImageUrl);
+ Assert.Null(program.ThumbImageUrl);
+ Assert.Null(program.BackdropImageUrl);
+ Assert.Null(program.OfficialRating);
+ Assert.Null(program.CommunityRating);
+ AssertXmlTvEtag(program.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")]
+ [InlineData("https://example.com/no-optional-elements.xml")]
+ public async Task GetChannels_NoOptionalElements_Success(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = "no-optional-elements-channels",
+ Path = path
+ };
+
+ var channels = await _xmlTvListingsProvider.GetChannels(info, CancellationToken.None);
+ var channel = Assert.Single(channels);
+ Assert.Equal("3297", channel.Id);
+ Assert.Equal("Channel Without Icon", channel.Name);
+ Assert.Equal("3297", channel.Number);
+ Assert.Null(channel.ImageUrl);
+ }
+
[Fact]
public async Task GetProgramsAsync_Etag_SameContentIsStable()
{
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml
new file mode 100644
index 0000000000..e82d00c259
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml
@@ -0,0 +1,10 @@
+<tv date="20221104">
+ <channel id="3297">
+ <display-name>Channel Without Icon</display-name>
+ </channel>
+ <programme channel="3297" start="20221104130000 +0000" stop="20221105235959 +0000">
+ <title lang="en">Programme Without Icon Or Rating</title>
+ <desc lang="en">A programme that only uses the required XMLTV elements.</desc>
+ <category lang="en">sports</category>
+ </programme>
+</tv>