mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-29 02:50:55 +01:00
Merge branch 'master' into fix/livetv-channel-icon-refresh
Resolve GuideManager conflict by keeping LiveTvChannelImageHelper so channel icons re-fetch on every guide refresh, including when the URL is unchanged.
This commit is contained in:
@@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities
|
||||
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
|
||||
|
||||
Username = username;
|
||||
NormalizedUsername = username.ToUpperInvariant();
|
||||
AuthenticationProviderId = authenticationProviderId;
|
||||
PasswordResetProviderId = passwordResetProviderId;
|
||||
|
||||
@@ -73,6 +74,16 @@ namespace Jellyfin.Database.Implementations.Entities
|
||||
[StringLength(255)]
|
||||
public string Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user's normalized name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Required, Max length = 255.
|
||||
/// </remarks>
|
||||
[MaxLength(255)]
|
||||
[StringLength(255)]
|
||||
public string NormalizedUsername { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user's password, or <c>null</c> if none is set.
|
||||
/// </summary>
|
||||
|
||||
@@ -108,5 +108,50 @@ public enum ViewType
|
||||
/// <summary>
|
||||
/// Shows upcoming.
|
||||
/// </summary>
|
||||
Upcoming = 20
|
||||
Upcoming = 20,
|
||||
|
||||
/// <summary>
|
||||
/// Shows authors.
|
||||
/// </summary>
|
||||
Authors = 21,
|
||||
|
||||
/// <summary>
|
||||
/// Shows books.
|
||||
/// </summary>
|
||||
Books = 22,
|
||||
|
||||
/// <summary>
|
||||
/// Shows folders.
|
||||
/// </summary>
|
||||
Folders = 23,
|
||||
|
||||
/// <summary>
|
||||
/// Shows mixed media.
|
||||
/// </summary>
|
||||
Mixed = 24,
|
||||
|
||||
/// <summary>
|
||||
/// Shows photos.
|
||||
/// </summary>
|
||||
Photos = 25,
|
||||
|
||||
/// <summary>
|
||||
/// Shows photo albums.
|
||||
/// </summary>
|
||||
PhotoAlbums = 26,
|
||||
|
||||
/// <summary>
|
||||
/// Shows series timers.
|
||||
/// </summary>
|
||||
SeriesTimers = 27,
|
||||
|
||||
/// <summary>
|
||||
/// Shows studios.
|
||||
/// </summary>
|
||||
Studios = 28,
|
||||
|
||||
/// <summary>
|
||||
/// Shows videos.
|
||||
/// </summary>
|
||||
Videos = 29
|
||||
}
|
||||
|
||||
@@ -111,6 +111,92 @@ public static class JellyfinQueryHelperExtensions
|
||||
&& val.map.ItemId == item.Id) == EF.Constant(!invert);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters items that match any of the specified (provider name, value) pairs.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to arrays of values to match.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereHasAnyProviderIds(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string[]> providerIds)
|
||||
{
|
||||
var providerKeys = providerIds
|
||||
.SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
|
||||
.ToList();
|
||||
|
||||
if (providerKeys.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
return baseQuery.Where(e => e.Provider!.Any(p => providerKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters items that have any of the specified providers. Empty/null values match any value for that provider.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to optional values.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereHasAnyProviderId(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string> providerIds)
|
||||
{
|
||||
var existenceOnly = providerIds
|
||||
.Where(e => string.IsNullOrEmpty(e.Value))
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
|
||||
var specificValues = providerIds
|
||||
.Where(e => !string.IsNullOrEmpty(e.Value))
|
||||
.Select(e => $"{e.Key}:{e.Value}")
|
||||
.ToList();
|
||||
|
||||
if (existenceOnly.Count == 0 && specificValues.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
if (existenceOnly.Count == 0)
|
||||
{
|
||||
return baseQuery.Where(e => e.Provider!.Any(p =>
|
||||
specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
|
||||
if (specificValues.Count == 0)
|
||||
{
|
||||
return baseQuery.Where(e => e.Provider!.Any(p => existenceOnly.Contains(p.ProviderId)));
|
||||
}
|
||||
|
||||
// Single EXISTS over Provider with both predicates OR'd, instead of two separate subqueries.
|
||||
return baseQuery.Where(e => e.Provider!.Any(p =>
|
||||
existenceOnly.Contains(p.ProviderId) ||
|
||||
specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Excludes items that match any of the specified (provider name, value) pairs.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to values to exclude.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereExcludeProviderIds(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string> providerIds)
|
||||
{
|
||||
var excludeKeys = providerIds
|
||||
.Select(e => $"{e.Key}:{e.Value}")
|
||||
.ToList();
|
||||
|
||||
if (excludeKeys.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query.
|
||||
/// </summary>
|
||||
@@ -138,9 +224,10 @@ public static class JellyfinQueryHelperExtensions
|
||||
|
||||
var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
|
||||
|
||||
if (oneOf.Count < 4) // arbitrary value choosen.
|
||||
// Threshold picked from microbenchmarks on SQLite: inline IN(const,...) beats a
|
||||
// parameterized array lookup by ~5-10% up to ~32 elements.
|
||||
if (oneOf.Count <= 32)
|
||||
{
|
||||
// if we have 3 or fewer values to check against its faster to do a IN(const,const,const) lookup
|
||||
return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
|
||||
builder
|
||||
.HasIndex(entity => entity.Username)
|
||||
.IsUnique();
|
||||
|
||||
builder
|
||||
.HasIndex(entity => entity.NormalizedUsername)
|
||||
.IsUnique();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNormalizedUsername : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NormalizedUsername",
|
||||
table: "Users",
|
||||
type: "TEXT",
|
||||
maxLength: 255,
|
||||
nullable: false,
|
||||
defaultValue: string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("ALTER TABLE Users DROP COLUMN NormalizedUsername;");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"DELETE FROM __EFMigrationsHistory
|
||||
WHERE MigrationId = '20260522092304_UpdateNormalizedUsername'");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUniqueNormalizedUsernameIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_NormalizedUsername",
|
||||
table: "Users",
|
||||
column: "NormalizedUsername",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_NormalizedUsername",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.7");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
|
||||
{
|
||||
@@ -1348,6 +1348,11 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<bool>("MustUpdatePassword")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NormalizedUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasMaxLength(65535)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -1390,6 +1395,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedUsername")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Class BaseExtensions.
|
||||
/// Extension methods for the <see cref="Stream"/> class.
|
||||
/// </summary>
|
||||
public static class StreamExtensions
|
||||
{
|
||||
private const int StreamComparisonBufferSize = 81920;
|
||||
|
||||
/// <summary>
|
||||
/// Reads all lines in the <see cref="Stream" />.
|
||||
/// </summary>
|
||||
@@ -60,5 +65,172 @@ namespace Jellyfin.Extensions
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a stream is identical to a file on disk.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to compare.</param>
|
||||
/// <param name="path">The file path to compare against.</param>
|
||||
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
|
||||
/// <returns>True if the stream and file are identical; otherwise false.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="stream"/> does not support seeking.</exception>
|
||||
/// <remarks>
|
||||
/// The entire stream is compared against the file from the beginning (the position is reset to 0 on entry)
|
||||
/// and restored to its original value after the call.
|
||||
/// </remarks>
|
||||
public static async Task<bool> IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
if (!stream.CanSeek)
|
||||
{
|
||||
throw new ArgumentException("Stream must support seeking.", nameof(stream));
|
||||
}
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
try
|
||||
{
|
||||
stream.Position = 0;
|
||||
|
||||
var existingFileStream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: StreamComparisonBufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using (existingFileStream.ConfigureAwait(false))
|
||||
{
|
||||
return await stream.IsStreamIdenticalAsync(existingFileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two streams are identical.
|
||||
/// </summary>
|
||||
/// <param name="a">The first stream to compare.</param>
|
||||
/// <param name="b">The second stream to compare.</param>
|
||||
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
|
||||
/// <returns>True if the streams are identical; otherwise false.</returns>
|
||||
/// <remarks>
|
||||
/// Seekable streams are compared from the beginning (their position is reset to 0 on entry).
|
||||
/// Non-seekable streams are compared from their current read position. Stream positions are not
|
||||
/// restored after the call.
|
||||
/// </remarks>
|
||||
public static async Task<bool> IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(a);
|
||||
ArgumentNullException.ThrowIfNull(b);
|
||||
|
||||
if (ReferenceEquals(a, b))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a.CanSeek is var aCanSeek && aCanSeek)
|
||||
{
|
||||
a.Position = 0;
|
||||
}
|
||||
|
||||
if (b.CanSeek is var bCanSeek && bCanSeek)
|
||||
{
|
||||
b.Position = 0;
|
||||
}
|
||||
|
||||
if (aCanSeek && bCanSeek && b.Length != a.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// MemoryStreams only unlock a fast path if their underlying buffer is exposed via TryGetBuffer.
|
||||
var segmentA = a is MemoryStream streamA && streamA.TryGetBuffer(out var bufA) ? bufA : default;
|
||||
var segmentB = b is MemoryStream streamB && streamB.TryGetBuffer(out var bufB) ? bufB : default;
|
||||
|
||||
// Fast path A: both streams expose buffers, compare segments directly
|
||||
if (segmentA.Array is not null && segmentB.Array is not null)
|
||||
{
|
||||
return segmentA.AsSpan().SequenceEqual(segmentB.AsSpan());
|
||||
}
|
||||
|
||||
if (segmentB.Array is not null) // && segmentA.Array is null guaranteed by previous check
|
||||
{
|
||||
// swap so that segmentA is the non-null one, compared to b we need only one fast path B
|
||||
(segmentA, b) = (segmentB, a);
|
||||
}
|
||||
|
||||
if (segmentA.Array is not null) // either a was non-null, or b was non-null and was swapped there
|
||||
{
|
||||
// Fast path B: only one stream exposed a buffer, compare against the other chunk-by-chunk
|
||||
var bufferB = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize);
|
||||
try
|
||||
{
|
||||
var memoryB = bufferB.AsMemory();
|
||||
int offset = 0;
|
||||
int bytesRead;
|
||||
while ((bytesRead = await b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (offset + bytesRead > segmentA.Count || !segmentA.AsSpan(offset, bytesRead).SequenceEqual(memoryB.Span[..bytesRead]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
offset += bytesRead;
|
||||
}
|
||||
|
||||
return offset == segmentA.Count;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(bufferB);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var bufferA = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize);
|
||||
var bufferB = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize);
|
||||
try
|
||||
{
|
||||
var memoryA = bufferA.AsMemory();
|
||||
var memoryB = bufferB.AsMemory();
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var taskA = a.ReadAtLeastAsync(memoryA, memoryA.Length, throwOnEndOfStream: false, cancellationToken).AsTask();
|
||||
var taskB = b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).AsTask();
|
||||
await Task.WhenAll(taskA, taskB).ConfigureAwait(false);
|
||||
|
||||
var bytesReadA = await taskA.ConfigureAwait(false);
|
||||
var bytesReadB = await taskB.ConfigureAwait(false);
|
||||
|
||||
if (bytesReadA != bytesReadB)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bytesReadA == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!memoryA.Span[..bytesReadA].SequenceEqual(memoryB.Span[..bytesReadB]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(bufferA);
|
||||
ArrayPool<byte>.Shared.Return(bufferB);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,27 +684,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
|
||||
|
||||
@@ -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>
|
||||
/// Invalid password hash.
|
||||
/// Schedules Direct busy.
|
||||
/// </summary>
|
||||
InvalidHash = 4003,
|
||||
|
||||
/// <summary>
|
||||
/// Account locked or disabled.
|
||||
/// </summary>
|
||||
AccountLocked = 4004,
|
||||
ServiceBusy = 3001,
|
||||
|
||||
/// <summary>
|
||||
/// Account expired.
|
||||
/// </summary>
|
||||
AccountExpired = 4005,
|
||||
AccountExpired = 4001,
|
||||
|
||||
/// <summary>
|
||||
/// Token has expired.
|
||||
/// Invalid password hash.
|
||||
/// </summary>
|
||||
InvalidHash = 4002,
|
||||
|
||||
/// <summary>
|
||||
/// Invalid user or password.
|
||||
/// </summary>
|
||||
InvalidUser = 4003,
|
||||
|
||||
/// <summary>
|
||||
/// Account temporarily locked due to login failures.
|
||||
/// </summary>
|
||||
AccountTempLock = 4004,
|
||||
|
||||
/// <summary>
|
||||
/// Account permanently locked due to abuse.
|
||||
/// </summary>
|
||||
AccountLocked = 4005,
|
||||
|
||||
/// <summary>
|
||||
/// 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,17 +63,32 @@ public enum SdErrorCode
|
||||
MaxLoginAttempts = 4009,
|
||||
|
||||
/// <summary>
|
||||
/// Temporary lockout.
|
||||
/// Maximum unique IP attempts reached.
|
||||
/// </summary>
|
||||
TemporaryLockout = 4010,
|
||||
MaxIPAttempts = 4010,
|
||||
|
||||
/// <summary>
|
||||
/// Lineup change maximum reached.
|
||||
/// </summary>
|
||||
MaxScheduleRequests = 4100,
|
||||
|
||||
/// <summary>
|
||||
/// Requested image not found.
|
||||
/// </summary>
|
||||
ImageNotFound = 5000,
|
||||
|
||||
/// <summary>
|
||||
/// Maximum image downloads reached for the day.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -180,6 +181,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 +208,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)
|
||||
|
||||
@@ -60,6 +60,7 @@ public class KeyframeExtractionScheduledTask : IScheduledTask
|
||||
DtoOptions = new DtoOptions(true),
|
||||
SourceTypes = [SourceType.Library],
|
||||
Recursive = true,
|
||||
IncludeOwnedItems = true,
|
||||
Limit = Pagesize
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user