Merge remote-tracking branch 'upstream/master' into search-rebased

This commit is contained in:
Shadowghost
2026-05-12 22:50:16 +02:00
185 changed files with 9112 additions and 1204 deletions

View File

@@ -96,6 +96,8 @@ public class BaseItemEntity
public string? OriginalTitle { get; set; }
public string? OriginalLanguage { get; set; }
public Guid? PrimaryVersionId { get; set; }
public DateTime? DateLastMediaAdded { get; set; }

View File

@@ -62,6 +62,16 @@ namespace Jellyfin.Database.Implementations.Entities.Libraries
[StringLength(1024)]
public string? OriginalTitle { get; set; }
/// <summary>
/// Gets or sets the original language.
/// </summary>
/// <remarks>
/// Max length = 1024.
/// </remarks>
[MaxLength(1024)]
[StringLength(1024)]
public string? OriginalLanguage { get; set; }
/// <summary>
/// Gets or sets the sort title.
/// </summary>

View File

@@ -40,6 +40,8 @@ public class MediaStreamInfo
public bool IsExternal { get; set; }
public bool IsOriginal { get; set; }
public int? Height { get; set; }
public int? Width { get; set; }

View File

@@ -73,6 +73,10 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
builder.HasIndex(e => e.SeasonId);
builder.HasIndex(e => e.SeriesId);
// Items/Counts: SELECT Type, COUNT(*) GROUP BY Type filtered by TopParentId.
builder.HasIndex(e => new { e.TopParentId, e.Type, e.IsVirtualItem })
.HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
builder.HasData(new BaseItemEntity()
{
Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddPartialIndexForItemCounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem",
table: "BaseItems",
columns: new[] { "TopParentId", "Type", "IsVirtualItem" },
filter: "\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem",
table: "BaseItems");
}
}
}

View File

@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddOriginalLanguage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsOriginal",
table: "MediaStreamInfos",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "OriginalLanguage",
table: "BaseItems",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "BaseItems",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "OriginalLanguage",
value: null);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsOriginal",
table: "MediaStreamInfos");
migrationBuilder.DropColumn(
name: "OriginalLanguage",
table: "BaseItems");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@@ -264,6 +264,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("OfficialRating")
.HasColumnType("TEXT");
b.Property<string>("OriginalLanguage")
.HasColumnType("TEXT");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
@@ -382,6 +385,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasIndex("Type", "CleanName");
b.HasIndex("TopParentId", "Type", "IsVirtualItem")
.HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
b.HasIndex("Type", "TopParentId", "Id");
b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
@@ -952,6 +958,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<bool?>("IsInterlaced")
.HasColumnType("INTEGER");
b.Property<bool>("IsOriginal")
.HasColumnType("INTEGER");
b.Property<string>("KeyFrames")
.HasColumnType("TEXT");

View File

@@ -15,7 +15,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Extensions</PackageId>
<VersionPrefix>10.12.0</VersionPrefix>
<VersionPrefix>12.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>

View File

@@ -774,7 +774,10 @@ namespace Jellyfin.LiveTv
}
}
SearchForDuplicateShowIds(enabledTimersForSeries);
if (seriesTimer.SkipEpisodesInLibrary)
{
SearchForDuplicateShowIds(enabledTimersForSeries);
}
if (deleteInvalidTimers)
{

View File

@@ -40,7 +40,9 @@ public static class LiveTvServiceCollectionExtensions
services.AddSingleton<ILiveTvService, DefaultLiveTvService>();
services.AddSingleton<ITunerHost, HdHomerunHost>();
services.AddSingleton<ITunerHost, M3UTunerHost>();
services.AddSingleton<IListingsProvider, SchedulesDirect>();
services.AddSingleton<SchedulesDirect>();
services.AddSingleton<IListingsProvider>(s => s.GetRequiredService<SchedulesDirect>());
services.AddSingleton<ISchedulesDirectService>(s => s.GetRequiredService<SchedulesDirect>());
services.AddSingleton<IListingsProvider, XmlTvListingsProvider>();
}
}

View File

@@ -37,6 +37,7 @@ public class GuideManager : IGuideManager
private readonly ILiveTvManager _liveTvManager;
private readonly ITunerHostManager _tunerHostManager;
private readonly IRecordingsManager _recordingsManager;
private readonly ISchedulesDirectService _schedulesDirectService;
private readonly LiveTvDtoService _tvDtoService;
/// <summary>
@@ -55,6 +56,7 @@ public class GuideManager : IGuideManager
/// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
/// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
/// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
/// <param name="schedulesDirectService">The <see cref="ISchedulesDirectService"/>.</param>
/// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
public GuideManager(
ILogger<GuideManager> logger,
@@ -65,6 +67,7 @@ public class GuideManager : IGuideManager
ILiveTvManager liveTvManager,
ITunerHostManager tunerHostManager,
IRecordingsManager recordingsManager,
ISchedulesDirectService schedulesDirectService,
LiveTvDtoService tvDtoService)
{
_logger = logger;
@@ -75,6 +78,7 @@ public class GuideManager : IGuideManager
_liveTvManager = liveTvManager;
_tunerHostManager = tunerHostManager;
_recordingsManager = recordingsManager;
_schedulesDirectService = schedulesDirectService;
_tvDtoService = tvDtoService;
}
@@ -723,13 +727,25 @@ public class GuideManager : IGuideManager
private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
{
var sdLimitActive = _schedulesDirectService.IsImageDailyLimitActive();
await Parallel.ForEachAsync(
programs
.Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
.Where(p => !sdLimitActive || !p.ImageInfos.All(
img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
.DistinctBy(p => p.Id),
_cacheParallelOptions,
async (program, cancellationToken) =>
{
// Re-check: limit may have been set by a parallel task since the LINQ filter ran.
if (_schedulesDirectService.IsImageDailyLimitActive()
&& program.ImageInfos.All(
img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
{
return;
}
for (var i = 0; i < program.ImageInfos.Length; i++)
{
if (cancellationToken.IsCancellationRequested)
@@ -738,22 +754,31 @@ public class GuideManager : IGuideManager
}
var imageInfo = program.ImageInfos[i];
if (!imageInfo.IsLocalFile)
if (imageInfo.IsLocalFile)
{
_logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
try
{
program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
program,
imageInfo,
imageIndex: 0,
removeOnFailure: false)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
}
continue;
}
// Skip SD downloads once the daily limit has been hit.
if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)
&& _schedulesDirectService.IsImageDailyLimitActive())
{
continue;
}
_logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
try
{
program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
program,
imageInfo,
imageIndex: 0,
removeOnFailure: false)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
}
}
}).ConfigureAwait(false);

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -74,6 +75,9 @@ public class ListingsManager : IListingsManager
}
_config.SaveConfiguration("livetv", config);
InvalidateListingsProviderCache(info.Id);
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
return info;
@@ -87,6 +91,12 @@ public class ListingsManager : IListingsManager
config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
_config.SaveConfiguration("livetv", config);
if (!string.IsNullOrEmpty(id))
{
InvalidateListingsProviderCache(id);
}
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
}
@@ -322,6 +332,35 @@ public class ListingsManager : IListingsManager
return channelId;
}
private void InvalidateListingsProviderCache(string providerId)
{
// Clear in-memory EPG channel cache for this provider
_epgChannels.TryRemove(providerId, out _);
// Provider IDs are generated as Guid.NewGuid().ToString("N")
// reject anything else so we never use untrusted input in a path or log entry.
if (!Guid.TryParseExact(providerId, "N", out var providerGuid))
{
return;
}
// Delete the cached XMLTV file so a fresh copy is downloaded
var cachePath = _config.CommonApplicationPaths?.CachePath;
if (!string.IsNullOrEmpty(cachePath))
{
var safeId = providerGuid.ToString("N", CultureInfo.InvariantCulture);
var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml");
try
{
File.Delete(xmltvCacheFile);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", safeId);
}
}
}
private async Task<EpgChannelData> GetEpgChannels(
IListingsProvider provider,
ListingsProviderInfo info,

View File

@@ -6,6 +6,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
@@ -21,6 +22,7 @@ using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using Jellyfin.LiveTv.Guide;
using Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.LiveTv;
@@ -31,30 +33,45 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.LiveTv.Listings
{
public class SchedulesDirect : IListingsProvider, IDisposable
public class SchedulesDirect : IListingsProvider, ISchedulesDirectService, IDisposable
{
private const string ApiUrl = "https://json.schedulesdirect.org/20141201";
private const int CountryCacheDays = 7;
private readonly ILogger<SchedulesDirect> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IApplicationPaths _appPaths;
private readonly AsyncNonKeyedLocker _tokenLock = new(1);
private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new();
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private DateTime _lastErrorResponse;
private long _lastErrorResponseTicks;
private volatile bool _accountError;
private bool _disposed = false;
private byte[] _countriesCache;
private DateOnly? _imageLimitHitDate;
private DateOnly? _metadataLimitHitDate;
public SchedulesDirect(
ILogger<SchedulesDirect> logger,
IHttpClientFactory httpClientFactory)
IHttpClientFactory httpClientFactory,
IApplicationPaths appPaths)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_appPaths = appPaths;
_imageLimitHitDate = LoadDailyLimitDate(ImageLimitFilePath);
_metadataLimitHitDate = LoadDailyLimitDate(MetadataLimitFilePath);
}
/// <inheritdoc />
public string Name => "Schedules Direct";
private string ImageLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-image-limit.txt");
private string MetadataLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-metadata-limit.txt");
/// <inheritdoc />
public string Type => nameof(SchedulesDirect);
@@ -76,6 +93,11 @@ namespace Jellyfin.LiveTv.Listings
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
{
if (IsMetadataLimitActive())
{
return [];
}
ArgumentException.ThrowIfNullOrEmpty(channelId);
// Normalize incoming input
@@ -149,7 +171,8 @@ namespace Jellyfin.LiveTv.Listings
var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays);
if (willBeCached && images is not null)
{
var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]);
var imageIndex = images.FindIndex(i =>
i.ProgramId is not null && schedule.ProgramId.StartsWith(i.ProgramId, StringComparison.Ordinal));
if (imageIndex > -1)
{
var programEntry = programDict[schedule.ProgramId];
@@ -451,39 +474,57 @@ namespace Jellyfin.LiveTv.Listings
IReadOnlyList<string> programIds,
CancellationToken cancellationToken)
{
if (IsImageDailyLimitActive())
{
return [];
}
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
if (programIds.Count == 0)
if (string.IsNullOrEmpty(token) || programIds.Count == 0)
{
return [];
}
StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13));
foreach (var i in programIds)
// SD API accepts max 500 program IDs per request
const int BatchSize = 500;
var results = new List<ShowImagesDto>();
for (int i = 0; i < programIds.Count; i += BatchSize)
{
str.Append('"')
.Append(i[..10])
.Append("\",");
var batch = programIds.Skip(i).Take(BatchSize);
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
message.Headers.TryAddWithoutValidation("token", token);
message.Content = JsonContent.Create(batch, options: _jsonOptions);
try
{
var batchResult = await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwait(false);
if (batchResult is not null)
{
foreach (var entry in batchResult)
{
if (entry.Code.HasValue)
{
_logger.LogWarning(
"Schedules Direct returned error for program {ProgramId}: code={Code}, message={Message}",
entry.ProgramId,
entry.Code,
entry.Message);
continue;
}
results.Add(entry);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting image info from schedules direct");
}
}
// Remove last ,
str.Length--;
str.Append(']');
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs");
message.Headers.TryAddWithoutValidation("token", token);
message.Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json);
try
{
return await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting image info from schedules direct");
return [];
}
return results;
}
public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken)
@@ -546,8 +587,14 @@ namespace Jellyfin.LiveTv.Listings
return null;
}
// Avoid hammering SD
if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1)
// Permanent account error — SD is disabled for this server lifetime.
if (_accountError)
{
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)
{
return null;
}
@@ -579,10 +626,16 @@ namespace Jellyfin.LiveTv.Listings
}
catch (HttpRequestException ex)
{
if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
// For 4xx errors not already handled by Request<T>'s SD code logic
// (e.g. unparseable response from the /token endpoint), apply a
// temporary backoff to avoid hammering SD.
if (!_accountError
&& ex.StatusCode.HasValue
&& (int)ex.StatusCode.Value >= 400
&& (int)ex.StatusCode.Value < 500)
{
_tokens.Clear();
_lastErrorResponse = DateTime.UtcNow;
Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
}
throw;
@@ -605,27 +658,75 @@ namespace Jellyfin.LiveTv.Listings
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false);
}
if (!enableRetry || (int)response.StatusCode >= 500)
{
_logger.LogError(
"Request to {Url} failed with response {Response}",
message.RequestUri,
await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new HttpRequestException(
string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
null,
response.StatusCode);
// Try to extract the Schedules Direct error code from the response body.
SdErrorCode? sdCode = null;
try
{
using var doc = JsonDocument.Parse(responseBody);
if (doc.RootElement.TryGetProperty("code", out var codeProp)
&& codeProp.TryGetInt32(out var parsedCode)
&& Enum.IsDefined((SdErrorCode)parsedCode))
{
sdCode = (SdErrorCode)parsedCode;
}
}
catch (JsonException)
{
// Response body is not valid JSON; sdCode stays null.
}
_tokens.Clear();
using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
retryMessage.Content = message.Content;
retryMessage.Headers.TryAddWithoutValidation(
"token",
await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
_logger.LogError(
"Request to {Url} failed with HTTP {StatusCode}, SD code {SdCode}: {Response}",
message.RequestUri,
(int)response.StatusCode,
sdCode?.ToString() ?? "N/A",
responseBody);
return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
if (sdCode is SdErrorCode.InvalidUser or SdErrorCode.InvalidHash or SdErrorCode.AccountLocked or SdErrorCode.AccountExpired or SdErrorCode.PasswordRequired)
{
// Permanent account errors — disable SD for this server lifetime.
_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)
{
// Transient login errors — back off for 30 minutes, then allow retry.
_tokens.Clear();
Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
}
else if (sdCode is SdErrorCode.MaxImageDownloads)
{
// Max image downloads — stop image requests until SD resets at 00:00 UTC.
SetImageLimitHit();
}
else if (sdCode is SdErrorCode.MaxScheduleRequests)
{
// Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
SetMetadataLimitHit();
}
else if (enableRetry
&& (int)response.StatusCode < 500
&& (sdCode == SdErrorCode.TokenExpired || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is null)))
{
// Token expired — clear tokens and retry with a fresh token.
// Also retry on 403 with no parseable SD code (legacy/unexpected auth failure).
_tokens.Clear();
using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
retryMessage.Content = message.Content;
retryMessage.Headers.TryAddWithoutValidation(
"token",
await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
}
throw new HttpRequestException(
string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
null,
response.StatusCode);
}
private async Task<string> GetTokenInternal(
@@ -706,6 +807,163 @@ namespace Jellyfin.LiveTv.Listings
}
}
/// <inheritdoc />
public async Task<Stream> GetAvailableCountries(CancellationToken cancellationToken)
{
if (_countriesCache is not null)
{
return new MemoryStream(_countriesCache, writable: false);
}
var cachePath = Path.Combine(_appPaths.CachePath, "sd-countries.json");
if (File.Exists(cachePath)
&& DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath) < TimeSpan.FromDays(CountryCacheDays))
{
try
{
_countriesCache = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
return new MemoryStream(_countriesCache, writable: false);
}
catch (IOException)
{
// Corrupt or unreadable — delete and re-fetch.
TryDeleteFile(cachePath);
}
}
var client = _httpClientFactory.CreateClient(NamedClient.Default);
using var response = await client.GetAsync(new Uri(ApiUrl + "/available/countries"), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
await File.WriteAllBytesAsync(cachePath, bytes, cancellationToken).ConfigureAwait(false);
_countriesCache = bytes;
return new MemoryStream(bytes, writable: false);
}
private static DateOnly? LoadDailyLimitDate(string path)
{
if (!File.Exists(path))
{
return null;
}
try
{
var text = File.ReadAllText(path).Trim();
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
{
var dateOnly = DateOnly.FromDateTime(date);
if (dateOnly < DateOnly.FromDateTime(DateTime.UtcNow))
{
// Expired — clean up.
File.Delete(path);
return null;
}
return dateOnly;
}
}
catch (IOException)
{
// Corrupt or unreadable — delete and reset.
TryDeleteFile(path);
}
return null;
}
/// <inheritdoc />
public bool IsServiceAvailable()
{
if (_accountError)
{
return false;
}
if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30)
{
return false;
}
return true;
}
/// <inheritdoc />
public bool IsImageDailyLimitActive()
{
if (!_imageLimitHitDate.HasValue)
{
return false;
}
if (_imageLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
{
_imageLimitHitDate = null;
TryDeleteFile(ImageLimitFilePath);
return false;
}
return true;
}
private bool IsMetadataLimitActive()
{
if (!_metadataLimitHitDate.HasValue)
{
return false;
}
if (_metadataLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
{
_metadataLimitHitDate = null;
TryDeleteFile(MetadataLimitFilePath);
return false;
}
return true;
}
private void SetImageLimitHit()
{
_imageLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
PersistDailyLimitFile(ImageLimitFilePath);
}
private void SetMetadataLimitHit()
{
_metadataLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
PersistDailyLimitFile(MetadataLimitFilePath);
}
private void PersistDailyLimitFile(string filePath)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
File.WriteAllText(filePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Failed to persist SD daily limit to {Path}", filePath);
}
}
private static void TryDeleteFile(string path)
{
try
{
File.Delete(path);
}
catch (IOException)
{
// Best effort.
}
}
public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
{
if (validateLogin)
@@ -735,11 +993,17 @@ namespace Jellyfin.LiveTv.Listings
public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
{
var listingsId = info.ListingsId;
ArgumentException.ThrowIfNullOrEmpty(listingsId);
if (string.IsNullOrEmpty(listingsId))
{
return [];
}
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
ArgumentException.ThrowIfNullOrEmpty(token);
if (string.IsNullOrEmpty(token))
{
return [];
}
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
options.Headers.TryAddWithoutValidation("token", token);

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
/// <summary>
/// Converter for the <c>data</c> field in SD image responses.
/// The Schedules Direct API may return a non-array value (e.g. a string error message)
/// instead of the expected image data array for programs with errors.
/// This converter returns an empty list for any non-array value.
/// </summary>
public sealed class ImageDataArrayConverter : JsonConverter<IReadOnlyList<ImageDataDto>>
{
/// <inheritdoc />
public override IReadOnlyList<ImageDataDto> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.StartArray)
{
var result = new List<ImageDataDto>();
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
var item = JsonSerializer.Deserialize<ImageDataDto>(ref reader, options);
if (item is not null)
{
result.Add(item);
}
}
return result;
}
// Not an array (string error, null, object, etc.) — skip and return empty.
reader.TrySkip();
return [];
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, IReadOnlyList<ImageDataDto> value, JsonSerializerOptions options)
=> JsonSerializer.Serialize(writer, value, options);
}

View File

@@ -0,0 +1,59 @@
#pragma warning disable CA1008 // Enums should have zero value
namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
/// <summary>
/// Schedules Direct API error codes.
/// </summary>
public enum SdErrorCode
{
/// <summary>
/// Invalid user.
/// </summary>
InvalidUser = 4001,
/// <summary>
/// Invalid password hash.
/// </summary>
InvalidHash = 4003,
/// <summary>
/// Account locked or disabled.
/// </summary>
AccountLocked = 4004,
/// <summary>
/// Account expired.
/// </summary>
AccountExpired = 4005,
/// <summary>
/// Token has expired.
/// </summary>
TokenExpired = 4006,
/// <summary>
/// Password is required.
/// </summary>
PasswordRequired = 4008,
/// <summary>
/// Maximum login attempts exceeded.
/// </summary>
MaxLoginAttempts = 4009,
/// <summary>
/// Temporary lockout.
/// </summary>
TemporaryLockout = 4010,
/// <summary>
/// Maximum image downloads reached for the day.
/// </summary>
MaxImageDownloads = 5002,
/// <summary>
/// Maximum schedule/metadata requests reached for the day.
/// </summary>
MaxScheduleRequests = 5003
}

View File

@@ -15,10 +15,23 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos
[JsonPropertyName("programID")]
public string? ProgramId { get; set; }
/// <summary>
/// Gets or sets the SD error code, if the request for this program failed.
/// </summary>
[JsonPropertyName("code")]
public int? Code { get; set; }
/// <summary>
/// Gets or sets the SD error message, if the request for this program failed.
/// </summary>
[JsonPropertyName("message")]
public string? Message { get; set; }
/// <summary>
/// Gets or sets the list of data.
/// </summary>
[JsonPropertyName("data")]
[JsonConverter(typeof(ImageDataArrayConverter))]
public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>();
}
}

View File

@@ -77,25 +77,39 @@ namespace Jellyfin.LiveTv.Listings
Directory.CreateDirectory(cacheDir);
}
if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
try
{
_logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false);
var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false);
var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
}
}
else
{
var stream = AsyncFile.OpenRead(info.Path);
await using (stream.ConfigureAwait(false))
{
return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
}
}
}
else
catch (Exception ex)
{
var stream = AsyncFile.OpenRead(info.Path);
await using (stream.ConfigureAwait(false))
_logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path);
if (File.Exists(cacheFile))
{
return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
File.Delete(cacheFile);
}
throw;
}
}
@@ -128,9 +142,20 @@ namespace Jellyfin.LiveTv.Listings
{
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
return file;
}
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)

View File

@@ -1204,7 +1204,7 @@ namespace Jellyfin.LiveTv
{
Services = services,
IsEnabled = services.Length > 0,
EnabledUsers = _userManager.Users
EnabledUsers = _userManager.GetUsers()
.Where(IsLiveTvEnabled)
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
.ToArray()
@@ -1220,7 +1220,7 @@ namespace Jellyfin.LiveTv
public IEnumerable<User> GetEnabledUsers()
{
return _userManager.Users
return _userManager.GetUsers()
.Where(IsLiveTvEnabled);
}

View File

@@ -79,7 +79,7 @@ namespace Jellyfin.LiveTv.Recordings
private async Task SendMessage(SessionMessageType name, TimerEventInfo info)
{
var users = _userManager.Users
var users = _userManager.GetUsers()
.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess))
.Select(i => i.Id)
.ToList();

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
@@ -99,6 +100,33 @@ public class TunerHostManager : ITunerHostManager
return info;
}
/// <inheritdoc />
public void DeleteTunerHost(string? id)
{
var config = _config.GetLiveTvConfiguration();
config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
_config.SaveConfiguration("livetv", config);
// Clean up the disk cache file for this tuner.
// Tuner IDs are generated as Guid.NewGuid().ToString("N")
// reject anything else so we never use untrusted input in a path or log entry
if (Guid.TryParseExact(id, "N", out var tunerGuid))
{
var safeId = tunerGuid.ToString("N", CultureInfo.InvariantCulture);
var channelCacheFile = Path.Combine(_config.CommonApplicationPaths.CachePath, safeId + "_channels");
try
{
File.Delete(channelCacheFile);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Error deleting channel cache file for tuner {TunerId}", safeId);
}
}
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
}
/// <inheritdoc />
public async IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly)
{

View File

@@ -156,7 +156,7 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator
{
if (keyframeData.KeyframeTicks.Count > 0 && keyframeData.TotalDuration < keyframeData.KeyframeTicks[^1])
{
throw new ArgumentException("Invalid duration in keyframe data", nameof(keyframeData));
keyframeData = new KeyframeData(keyframeData.KeyframeTicks[^1], keyframeData.KeyframeTicks);
}
long lastKeyframe = 0;
@@ -176,7 +176,12 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator
}
}
result.Add(TimeSpan.FromTicks(keyframeData.TotalDuration - lastKeyframe).TotalSeconds);
var remaining = keyframeData.TotalDuration - lastKeyframe;
if (remaining > 0)
{
result.Add(TimeSpan.FromTicks(remaining).TotalSeconds);
}
return result;
}

View File

@@ -316,7 +316,7 @@ public class NetworkManager : INetworkManager, IDisposable
var subnets = config.LocalNetworkSubnets;
// If no LAN addresses are specified, all private subnets and Loopback are deemed to be the LAN
if (!NetworkUtils.TryParseToSubnets(subnets, out var lanSubnets, false) || lanSubnets.Count == 0)
if (!NetworkUtils.TryParseToSubnets(subnets, out var lanSubnets, false, _logger) || lanSubnets.Count == 0)
{
_logger.LogDebug("Using LAN interface addresses as user provided no LAN details.");
@@ -343,7 +343,7 @@ public class NetworkManager : INetworkManager, IDisposable
_lanSubnets = lanSubnets.Select(x => x.Subnet).ToArray();
}
_excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true)
_excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true, _logger)
? excludedSubnets.Select(x => x.Subnet).ToArray()
: Array.Empty<IPNetwork>();
}