aboutsummaryrefslogtreecommitdiff
path: root/src/Jellyfin.LiveTv
diff options
context:
space:
mode:
Diffstat (limited to 'src/Jellyfin.LiveTv')
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelManager.cs2
-rw-r--r--src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs4
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs36
-rw-r--r--src/Jellyfin.LiveTv/IO/EncodedRecorder.cs5
-rw-r--r--src/Jellyfin.LiveTv/Listings/ListingsManager.cs4
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs40
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs63
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs29
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs184
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs7
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs6
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs4
-rw-r--r--src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs4
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs2
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs2
15 files changed, 342 insertions, 50 deletions
diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
index 2b8e5a0a08..ed02fe6a1d 100644
--- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
+++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
@@ -1129,7 +1129,7 @@ namespace Jellyfin.LiveTv.Channels
{
if (!item.Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase))
{
- item.Tags = [..item.Tags, "livestream"];
+ item.Tags = [.. item.Tags, "livestream"];
_logger.LogDebug("Forcing update due to Tags {0}", item.Name);
forceUpdate = true;
}
diff --git a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs
index 71e46764ad..bb4238a2ac 100644
--- a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs
+++ b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs
@@ -40,10 +40,10 @@ namespace Jellyfin.LiveTv.Channels
}
/// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TasksRefreshChannels");
+ public string Name => _localization.GetLocalizedString("TaskRefreshChannels");
/// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TasksRefreshChannelsDescription");
+ public string Description => _localization.GetLocalizedString("TaskRefreshChannelsDescription");
/// <inheritdoc />
public string Category => _localization.GetLocalizedString("TasksChannelsCategory");
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index 556516674b..41520f8789 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;
@@ -448,14 +449,19 @@ public class GuideManager : IGuideManager
item.Name = channelInfo.Name;
- if (!item.HasImage(ImageType.Primary))
+ var currentPrimary = item.GetImageInfo(ImageType.Primary, 0);
+ var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl);
+
+ // Update channel image if image URL has changed
+ if (currentPrimary is null
+ || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal)))
{
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
{
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
forceUpdate = true;
}
- else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
+ else if (!imageUrlIsNull)
{
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
forceUpdate = true;
@@ -494,8 +500,13 @@ public class GuideManager : IGuideManager
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
-
- 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))
@@ -621,13 +632,9 @@ public class GuideManager : IGuideManager
forceUpdate |= UpdateImages(item, info);
- if (isNew)
- {
- item.OnMetadataChanged();
-
- return (item, true, false);
- }
-
+ // Restore the etag wiped by `item.ProviderIds = info.ProviderIds` above and
+ // persist it on new items so they join the fast path on the next refresh
+ // instead of taking an extra full processing cycle.
var isUpdated = forceUpdate;
var etag = info.Etag;
if (string.IsNullOrWhiteSpace(etag))
@@ -640,6 +647,13 @@ public class GuideManager : IGuideManager
isUpdated = true;
}
+ if (isNew)
+ {
+ item.OnMetadataChanged();
+
+ return (item, true, false);
+ }
+
if (isUpdated)
{
item.OnMetadataChanged();
diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
index d877a0d124..633c4f95ed 100644
--- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
@@ -83,6 +83,7 @@ namespace Jellyfin.LiveTv.IO
CreateNoWindow = true,
UseShellExecute = false,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true,
RedirectStandardInput = true,
@@ -187,8 +188,8 @@ namespace Jellyfin.LiveTv.IO
var commandLineArgs = string.Format(
CultureInfo.InvariantCulture,
"-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"",
- inputTempFile,
- targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename
+ inputTempFile.EscapeProcessArgument(),
+ targetFile.EscapeProcessArgument(),
videoArgs,
GetAudioArgs(mediaSource),
subtitleArgs,
diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
index 58683deb30..15e20d6f64 100644
--- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
+++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
@@ -67,7 +67,7 @@ public class ListingsManager : IListingsManager
if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
{
info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
- config.ListingProviders = [..list, info];
+ config.ListingProviders = [.. list, info];
}
else
{
@@ -255,7 +255,7 @@ public class ListingsManager : IListingsManager
Name = tunerChannelNumber,
Value = providerChannelNumber
};
- listingsProviderInfo.ChannelMappings = [..listingsProviderInfo.ChannelMappings, newItem];
+ listingsProviderInfo.ChannelMappings = [.. listingsProviderInfo.ChannelMappings, newItem];
}
_config.SaveConfiguration("livetv", config);
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
index 3aa0f0408b..c93d1f039c 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
@@ -491,6 +491,12 @@ namespace Jellyfin.LiveTv.Listings
var results = new List<ShowImagesDto>();
for (int i = 0; i < programIds.Count; i += BatchSize)
{
+ // The daily image limit may be surfaced mid-batch.
+ if (IsImageDailyLimitActive())
+ {
+ break;
+ }
+
var batch = programIds.Skip(i).Take(BatchSize);
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
@@ -511,6 +517,18 @@ namespace Jellyfin.LiveTv.Listings
entry.ProgramId,
entry.Code,
entry.Message);
+
+ // The image download limit can be reported per-entry inside an
+ // otherwise successful (HTTP 200) response when the limit is hit
+ // mid-batch. Back off so we stop requesting images until SD resets.
+ if (entry.Code is (int)SdErrorCode.MaxImageDownloads or (int)SdErrorCode.MaxImageDownloadsTrial)
+ {
+ _logger.LogError(
+ "Schedules Direct image download limit hit (code {Code}). Disabling image acquisition until SD reset.",
+ entry.Code);
+ SetImageLimitHit();
+ }
+
continue;
}
@@ -684,27 +702,37 @@ namespace Jellyfin.LiveTv.Listings
sdCode?.ToString() ?? "N/A",
responseBody);
- if (sdCode is SdErrorCode.InvalidUser or SdErrorCode.InvalidHash or SdErrorCode.AccountLocked or SdErrorCode.AccountExpired or SdErrorCode.PasswordRequired)
+ if (sdCode is SdErrorCode.AccountExpired or SdErrorCode.InvalidHash or SdErrorCode.InvalidUser or SdErrorCode.AccountLocked or SdErrorCode.AppLocked or SdErrorCode.AccountInactive)
{
// Permanent account errors — disable SD for this server lifetime.
- _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart", sdCode);
+ _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart.", sdCode);
_tokens.Clear();
_accountError = true;
}
- else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.TemporaryLockout)
+ else if (sdCode is SdErrorCode.ServiceOffline or SdErrorCode.ServiceBusy or SdErrorCode.AccountTempLock)
{
// Transient login errors — back off for 30 minutes, then allow retry.
+ _logger.LogError("Schedules Direct transient error (code {SdCode}). Backing off for 30 minutes.", sdCode);
_tokens.Clear();
Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
}
- else if (sdCode is SdErrorCode.MaxImageDownloads)
+ else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.MaxIPAttempts)
+ {
+ // 24 hour bans - stop image and metadata requests until SD reset at 00:00 UTC.
+ _logger.LogError("Schedules Direct service limit error (code {SdCode}). Disabling until SD reset.", sdCode);
+ SetImageLimitHit();
+ SetMetadataLimitHit();
+ }
+ else if (sdCode is SdErrorCode.MaxImageDownloads or SdErrorCode.MaxImageDownloadsTrial)
{
// Max image downloads — stop image requests until SD resets at 00:00 UTC.
+ _logger.LogError("Schedules Direct image download limit hit (code {SdCode}). Disabling image acquisition until SD reset.", sdCode);
SetImageLimitHit();
}
else if (sdCode is SdErrorCode.MaxScheduleRequests)
{
// Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
+ _logger.LogError("Schedules Direct metadata download limit hit (code {SdCode}). Disabling metadata acquisition until SD reset.", sdCode);
SetMetadataLimitHit();
}
else if (enableRetry
@@ -738,9 +766,7 @@ namespace Jellyfin.LiveTv.Listings
#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
#pragma warning restore CA5350
- // TODO: remove ToLower when Convert.ToHexString supports lowercase
- // Schedules Direct requires the hex to be lowercase
- string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
+ string hashedPassword = Convert.ToHexStringLower(hashedPasswordBytes);
options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs
index ec6c6c475b..fffbfb9a58 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs
@@ -3,39 +3,59 @@
namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
/// <summary>
-/// Schedules Direct API error codes.
+/// Schedules Direct API error codes. See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#error-response for details.
/// </summary>
public enum SdErrorCode
{
/// <summary>
- /// Invalid user.
+ /// Schedules Direct unavailable/out of service.
/// </summary>
- InvalidUser = 4001,
+ ServiceOffline = 3000,
+
+ /// <summary>
+ /// Schedules Direct busy.
+ /// </summary>
+ ServiceBusy = 3001,
+
+ /// <summary>
+ /// Account expired.
+ /// </summary>
+ AccountExpired = 4001,
/// <summary>
/// Invalid password hash.
/// </summary>
- InvalidHash = 4003,
+ InvalidHash = 4002,
/// <summary>
- /// Account locked or disabled.
+ /// Invalid user or password.
/// </summary>
- AccountLocked = 4004,
+ InvalidUser = 4003,
/// <summary>
- /// Account expired.
+ /// Account temporarily locked due to login failures.
+ /// </summary>
+ AccountTempLock = 4004,
+
+ /// <summary>
+ /// Account permanently locked due to abuse.
/// </summary>
- AccountExpired = 4005,
+ AccountLocked = 4005,
/// <summary>
- /// Token has expired.
+ /// Token has expired. Request a new one.
/// </summary>
TokenExpired = 4006,
/// <summary>
- /// Password is required.
+ /// Application locked out.
/// </summary>
- PasswordRequired = 4008,
+ AppLocked = 4007,
+
+ /// <summary>
+ /// Account not active.
+ /// </summary>
+ AccountInactive = 4008,
/// <summary>
/// Maximum login attempts exceeded.
@@ -43,9 +63,19 @@ public enum SdErrorCode
MaxLoginAttempts = 4009,
/// <summary>
- /// Temporary lockout.
+ /// Maximum unique IP attempts reached.
+ /// </summary>
+ MaxIPAttempts = 4010,
+
+ /// <summary>
+ /// Lineup change maximum reached.
/// </summary>
- TemporaryLockout = 4010,
+ MaxScheduleRequests = 4100,
+
+ /// <summary>
+ /// Requested image not found.
+ /// </summary>
+ ImageNotFound = 5000,
/// <summary>
/// Maximum image downloads reached for the day.
@@ -53,7 +83,12 @@ public enum SdErrorCode
MaxImageDownloads = 5002,
/// <summary>
+ /// Trial specific maximum image downloads reached for the day.
+ /// </summary>
+ MaxImageDownloadsTrial = 5003,
+
+ /// <summary>
/// Maximum schedule/metadata requests reached for the day.
/// </summary>
- MaxScheduleRequests = 5003
+ MaxInvalidImages = 5004
}
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
index ec2e6cfcc9..0aeb7ad05d 100644
--- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
@@ -12,6 +12,7 @@ using System.Threading.Tasks;
using Jellyfin.Extensions;
using Jellyfin.XmlTv;
using Jellyfin.XmlTv.Entities;
+using Jellyfin.XmlTv.Enums;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
@@ -172,7 +173,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)
@@ -180,6 +203,8 @@ namespace Jellyfin.LiveTv.Listings
string? episodeTitle = program.Episode?.Title;
var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList();
var imageUrl = program.Icons.FirstOrDefault()?.Source;
+ var episodeImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Still)?.Path;
+ var backgroundImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Backdrop)?.Path;
var rating = program.Ratings.FirstOrDefault()?.Value;
var starRating = program.StarRatings?.FirstOrDefault()?.StarRating;
@@ -205,6 +230,8 @@ namespace Jellyfin.LiveTv.Listings
IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
ImageUrl = string.IsNullOrEmpty(imageUrl) ? null : imageUrl,
HasImage = !string.IsNullOrEmpty(imageUrl),
+ BackdropImageUrl = string.IsNullOrEmpty(backgroundImageUrl) ? null : backgroundImageUrl,
+ ThumbImageUrl = string.IsNullOrEmpty(episodeImageUrl) ? null : episodeImageUrl,
OfficialRating = string.IsNullOrEmpty(rating) ? null : rating,
CommunityRating = starRating is null ? null : (float)starRating.Value,
SeriesId = program.Episode?.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture)
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs
new file mode 100644
index 0000000000..b5ddb1530f
--- /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);
+
+ 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));
+
+ // 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 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/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index 2abc8a8c09..2edf7681db 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -178,6 +178,11 @@ namespace Jellyfin.LiveTv
{
var program = _libraryManager.GetItemById(id);
+ if (program is null)
+ {
+ return null;
+ }
+
var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user);
var list = new List<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)>
@@ -1257,7 +1262,7 @@ namespace Jellyfin.LiveTv
public Folder GetInternalLiveTvFolder(CancellationToken cancellationToken)
{
- var name = _localization.GetLocalizedString("HeaderLiveTV");
+ var name = _localization.GetServerLocalizedString("HeaderLiveTV");
return _libraryManager.GetNamedView(name, CollectionType.livetv, name);
}
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
index 846f9baf71..62a06370da 100644
--- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
@@ -497,7 +497,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
// trim trailing period from the folder name
var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim();
- if (metadata is not null && metadata.ProductionYear.HasValue)
+ if (metadata is not null && metadata.ProductionYear is not null)
{
folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
}
@@ -532,7 +532,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
}
var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
- if (timer.ProductionYear.HasValue)
+ if (timer.ProductionYear is not null)
{
folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
}
@@ -550,7 +550,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
}
var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
- if (timer.ProductionYear.HasValue)
+ if (timer.ProductionYear is not null)
{
folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
}
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
index 3a2c463695..e0e5a00fb9 100644
--- a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
@@ -288,9 +288,9 @@ public class RecordingsMetadataManager
null,
"dateadded",
null,
- DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ DateTime.UtcNow.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false);
- if (item.ProductionYear.HasValue)
+ if (item.ProductionYear is not null)
{
await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
}
diff --git a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
index 6a68b8c25c..74fa1415c6 100644
--- a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
+++ b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
@@ -116,7 +116,7 @@ namespace Jellyfin.LiveTv.Timers
throw new ArgumentException("item already exists", nameof(item));
}
- _items = [.._items, item];
+ _items = [.. _items, item];
SaveList();
}
@@ -131,7 +131,7 @@ namespace Jellyfin.LiveTv.Timers
int index = Array.FindIndex(_items, i => EqualityComparer(i, item));
if (index == -1)
{
- _items = [.._items, item];
+ _items = [.. _items, item];
}
else
{
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
index e1f87a7bd4..15b1368939 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
@@ -326,7 +326,7 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
BufferMs = 0,
Container = "ts",
Id = id,
- SupportsDirectPlay = false,
+ SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsInfiniteStream = true,
diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
index cfd763b6fd..7c16d2b363 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
@@ -83,7 +83,7 @@ public class TunerHostManager : ITunerHostManager
if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
{
info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
- config.TunerHosts = [..list, info];
+ config.TunerHosts = [.. list, info];
}
else
{