aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
4 files changed, 143 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))
{