diff options
Diffstat (limited to 'src')
9 files changed, 193 insertions, 11 deletions
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 4ae5a9a48..a158e5c86 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -19,8 +19,8 @@ namespace Jellyfin.Drawing.Skia; /// </summary> public class SkiaEncoder : IImageEncoder { + private const string SvgFormat = "svg"; private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" }; - private readonly ILogger<SkiaEncoder> _logger; private readonly IApplicationPaths _appPaths; private static readonly SKImageFilter _imageFilter; @@ -89,12 +89,13 @@ public class SkiaEncoder : IImageEncoder // working on windows at least "cr2", "nef", - "arw" + "arw", + SvgFormat }; /// <inheritdoc/> public IReadOnlyCollection<ImageFormat> SupportedOutputFormats - => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png }; + => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png, ImageFormat.Svg }; /// <summary> /// Check if the native lib is available. @@ -187,7 +188,8 @@ public class SkiaEncoder : IImageEncoder ArgumentException.ThrowIfNullOrEmpty(path); var extension = Path.GetExtension(path.AsSpan()).TrimStart('.'); - if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)) + if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase) + || extension.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase)) { _logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path); return string.Empty; @@ -312,6 +314,31 @@ public class SkiaEncoder : IImageEncoder return Decode(path, false, orientation, out _); } + private SKBitmap? GetBitmapFromSvg(string path) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException("File not found", path); + } + + using var svg = SKSvg.CreateFromFile(path); + if (svg.Drawable is null) + { + return null; + } + + var width = (int)Math.Round(svg.Drawable.Bounds.Width); + var height = (int)Math.Round(svg.Drawable.Bounds.Height); + + var bitmap = new SKBitmap(width, height); + using var canvas = new SKCanvas(bitmap); + canvas.DrawPicture(svg.Picture); + canvas.Flush(); + canvas.Save(); + + return bitmap; + } + private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) { var needsFlip = origin is SKEncodedOrigin.LeftBottom or SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.RightTop; @@ -402,6 +429,12 @@ public class SkiaEncoder : IImageEncoder return inputPath; } + if (outputFormat == ImageFormat.Svg + && !inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Requested svg output from {inputFormat} input"); + } + var skiaOutputFormat = GetImageFormat(outputFormat); var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor); @@ -409,7 +442,10 @@ public class SkiaEncoder : IImageEncoder var blur = options.Blur ?? 0; var hasIndicator = options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); - using var bitmap = GetBitmap(inputPath, autoOrient, orientation); + using var bitmap = inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase) + ? GetBitmapFromSvg(inputPath) + : GetBitmap(inputPath, autoOrient, orientation); + if (bitmap is null) { throw new InvalidDataException($"Skia unable to read image {inputPath}"); diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs new file mode 100644 index 000000000..06ecfc558 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs @@ -0,0 +1,49 @@ +using System; +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters; + +/// <summary> +/// Json unknown enum converter. +/// </summary> +/// <typeparam name="T">The type of enum.</typeparam> +public class JsonDefaultStringEnumConverter<T> : JsonConverter<T> + where T : struct, Enum +{ + private readonly JsonConverter<T> _baseConverter; + + /// <summary> + /// Initializes a new instance of the <see cref="JsonDefaultStringEnumConverter{T}"/> class. + /// </summary> + /// <param name="baseConverter">The base json converter.</param> + public JsonDefaultStringEnumConverter(JsonConverter<T> baseConverter) + { + _baseConverter = baseConverter; + } + + /// <inheritdoc /> + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.IsNull() || reader.IsEmptyString()) + { + var customValueAttribute = typeToConvert.GetCustomAttribute<DefaultValueAttribute>(); + if (customValueAttribute?.Value is null) + { + throw new InvalidOperationException($"Default value not set for '{typeToConvert.Name}'"); + } + + return (T)customValueAttribute.Value; + } + + return _baseConverter.Read(ref reader, typeToConvert, options); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + _baseConverter.Write(writer, value, options); + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs new file mode 100644 index 000000000..5a9bf546e --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs @@ -0,0 +1,31 @@ +using System; +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters; + +/// <summary> +/// Utilizes the JsonStringEnumConverter and sets a default value if not provided. +/// </summary> +public class JsonDefaultStringEnumConverterFactory : JsonConverterFactory +{ + private static readonly JsonStringEnumConverter _baseConverterFactory = new(); + + /// <inheritdoc /> + public override bool CanConvert(Type typeToConvert) + { + return _baseConverterFactory.CanConvert(typeToConvert) + && typeToConvert.IsDefined(typeof(DefaultValueAttribute)); + } + + /// <inheritdoc /> + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var baseConverter = _baseConverterFactory.CreateConverter(typeToConvert, options); + var converterType = typeof(JsonDefaultStringEnumConverter<>).MakeGenericType(typeToConvert); + + return (JsonConverter?)Activator.CreateInstance(converterType, baseConverter); + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs index ea6d141cb..2964c6943 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs @@ -12,7 +12,7 @@ namespace Jellyfin.Extensions.Json.Converters { /// <inheritdoc /> public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => reader.TokenType == JsonTokenType.Null + => reader.IsNull() ? Guid.Empty : ReadInternal(ref reader); diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs index 28437023f..94004fa49 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs @@ -15,10 +15,7 @@ namespace Jellyfin.Extensions.Json.Converters /// <inheritdoc /> public override TStruct? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - // Token is empty string. - if (reader.TokenType == JsonTokenType.String - && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty) - || (!reader.HasValueSequence && reader.ValueSpan.IsEmpty))) + if (reader.IsEmptyString()) { return null; } diff --git a/src/Jellyfin.Extensions/Json/JsonDefaults.cs b/src/Jellyfin.Extensions/Json/JsonDefaults.cs index 9e6d4c3f8..cbe5849ec 100644 --- a/src/Jellyfin.Extensions/Json/JsonDefaults.cs +++ b/src/Jellyfin.Extensions/Json/JsonDefaults.cs @@ -38,6 +38,7 @@ namespace Jellyfin.Extensions.Json new JsonNullableGuidConverter(), new JsonVersionConverter(), new JsonFlagEnumConverterFactory(), + new JsonDefaultStringEnumConverterFactory(), new JsonStringEnumConverter(), new JsonNullableStructConverterFactory(), new JsonDateTimeConverter(), diff --git a/src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs b/src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs new file mode 100644 index 000000000..d06508a26 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs @@ -0,0 +1,27 @@ +using System.Text.Json; + +namespace Jellyfin.Extensions.Json; + +/// <summary> +/// Extensions for Utf8JsonReader and Utf8JsonWriter. +/// </summary> +public static class Utf8JsonExtensions +{ + /// <summary> + /// Determines if the reader contains an empty string. + /// </summary> + /// <param name="reader">The reader.</param> + /// <returns>Whether the reader contains an empty string.</returns> + public static bool IsEmptyString(this Utf8JsonReader reader) + => reader.TokenType == JsonTokenType.String + && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty) + || (!reader.HasValueSequence && reader.ValueSpan.IsEmpty)); + + /// <summary> + /// Determines if the reader contains a null value. + /// </summary> + /// <param name="reader">The reader.</param> + /// <returns>Whether the reader contains null.</returns> + public static bool IsNull(this Utf8JsonReader reader) + => reader.TokenType == JsonTokenType.Null; +} diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index 1948a9ab9..cce2911dc 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -570,7 +570,6 @@ namespace Jellyfin.LiveTv.Channels return new ChannelFeatures(channel.Name, channel.Id) { CanFilter = !features.MaxPageSize.HasValue, - CanSearch = provider is ISearchableChannel, ContentTypes = features.ContentTypes.ToArray(), DefaultSortFields = features.DefaultSortFields.ToArray(), MaxPageSize = features.MaxPageSize, diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 39f174cc2..093970c38 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -27,6 +27,8 @@ public class GuideManager : IGuideManager private const string EtagKey = "ProgramEtag"; private const string ExternalServiceTag = "ExternalServiceId"; + private static readonly ParallelOptions _cacheParallelOptions = new() { MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, 10) }; + private readonly ILogger<GuideManager> _logger; private readonly IConfigurationManager _config; private readonly IFileSystem _fileSystem; @@ -209,6 +211,7 @@ public class GuideManager : IGuideManager _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); + var maxCacheDate = DateTime.UtcNow.AddDays(2); foreach (var currentChannel in list) { cancellationToken.ThrowIfCancellationRequested(); @@ -263,6 +266,7 @@ public class GuideManager : IGuideManager if (newPrograms.Count > 0) { _libraryManager.CreateItems(newPrograms, null, cancellationToken); + await PrecacheImages(newPrograms, maxCacheDate).ConfigureAwait(false); } if (updatedPrograms.Count > 0) @@ -272,6 +276,7 @@ public class GuideManager : IGuideManager currentChannel, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + await PrecacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false); } currentChannel.IsMovie = isMovie; @@ -708,4 +713,41 @@ public class GuideManager : IGuideManager return (item, isNew, isUpdated); } + + private async Task PrecacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate) + { + await Parallel.ForEachAsync( + programs + .Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate) + .DistinctBy(p => p.Id), + _cacheParallelOptions, + async (program, cancellationToken) => + { + for (var i = 0; i < program.ImageInfos.Length; i++) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var imageInfo = program.ImageInfos[i]; + if (!imageInfo.IsLocalFile) + { + try + { + program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal( + program, + imageInfo, + imageIndex: 0, + removeOnFailure: false) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to precache {Url}", imageInfo.Path); + } + } + } + }).ConfigureAwait(false); + } } |
