aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/codeql-analysis.yml2
-rw-r--r--.github/workflows/commands.yml4
-rw-r--r--.github/workflows/openapi.yml12
-rw-r--r--CONTRIBUTORS.md2
-rw-r--r--Emby.Drawing/ImageProcessor.cs569
-rw-r--r--Emby.Drawing/NullImageEncoder.cs58
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs2
-rw-r--r--Emby.Server.Implementations/Data/SqliteItemRepository.cs15
-rw-r--r--Emby.Server.Implementations/Emby.Server.Implementations.csproj4
-rw-r--r--Emby.Server.Implementations/Library/MediaStreamSelector.cs54
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs30
-rw-r--r--Emby.Server.Implementations/Localization/Core/af.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ar.json28
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json42
-rw-r--r--Emby.Server.Implementations/Localization/Core/eu.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json3
-rw-r--r--Emby.Server.Implementations/Localization/iso6392.txt1
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs11
-rw-r--r--Jellyfin.Api/Attributes/AcceptsFileAttribute.cs2
-rw-r--r--Jellyfin.Api/Attributes/ProducesFileAttribute.cs2
-rw-r--r--Jellyfin.Api/BaseJellyfinApiController.cs18
-rw-r--r--Jellyfin.Api/Controllers/ApiKeyController.cs2
-rw-r--r--Jellyfin.Api/Controllers/DynamicHlsController.cs5
-rw-r--r--Jellyfin.Api/Controllers/ImageController.cs135
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs3
-rw-r--r--Jellyfin.Api/Controllers/PackageController.cs4
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs3
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs40
-rw-r--r--Jellyfin.Api/Controllers/SyncPlayController.cs3
-rw-r--r--Jellyfin.Api/Controllers/SystemController.cs3
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs2
-rw-r--r--Jellyfin.Api/Jellyfin.Api.csproj6
-rw-r--r--Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs6
-rw-r--r--Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs36
-rw-r--r--Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs48
-rw-r--r--Jellyfin.Drawing.Skia/SkiaCodecException.cs45
-rw-r--r--Jellyfin.Drawing.Skia/SkiaEncoder.cs545
-rw-r--r--Jellyfin.Drawing.Skia/SkiaException.cs39
-rw-r--r--Jellyfin.Drawing.Skia/SkiaHelper.cs47
-rw-r--r--Jellyfin.Drawing.Skia/SplashscreenBuilder.cs148
-rw-r--r--Jellyfin.Drawing.Skia/StripCollageBuilder.cs186
-rw-r--r--Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs64
-rw-r--r--Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj14
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs6
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs4
-rw-r--r--Jellyfin.Server/CoreAppHost.cs2
-rw-r--r--Jellyfin.Server/Filters/FileRequestFilter.cs2
-rw-r--r--Jellyfin.Server/Filters/FileResponseFilter.cs2
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj10
-rw-r--r--Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs4
-rw-r--r--Jellyfin.sln6
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs54
-rw-r--r--MediaBrowser.Controller/Entities/TV/Episode.cs2
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj2
-rw-r--r--MediaBrowser.Controller/Session/ISessionManager.cs10
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs5
-rw-r--r--MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj4
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs44
-rw-r--r--MediaBrowser.Model/Configuration/ServerConfiguration.cs2
-rw-r--r--MediaBrowser.Model/LiveTv/LiveTvOptions.cs4
-rw-r--r--MediaBrowser.Providers/Manager/ImageSaver.cs3
-rw-r--r--MediaBrowser.Providers/Manager/ItemImageProvider.cs40
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs19
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj6
-rw-r--r--MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs23
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs36
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs13
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs42
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs36
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs28
-rw-r--r--MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs12
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs7
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs48
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs14
-rw-r--r--debian/postinst10
-rw-r--r--deployment/Dockerfile.centos.amd642
-rw-r--r--deployment/Dockerfile.fedora.amd642
-rw-r--r--deployment/Dockerfile.ubuntu.amd642
-rw-r--r--deployment/Dockerfile.ubuntu.arm642
-rw-r--r--deployment/Dockerfile.ubuntu.armhf2
-rw-r--r--fedora/jellyfin.spec3
-rw-r--r--jellyfin.ruleset6
-rw-r--r--src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj (renamed from Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj)8
-rw-r--r--src/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs35
-rw-r--r--src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs47
-rw-r--r--src/Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs (renamed from Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs)0
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaCodecException.cs44
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs544
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaException.cs38
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaHelper.cs46
-rw-r--r--src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs147
-rw-r--r--src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs185
-rw-r--r--src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs63
-rw-r--r--src/Jellyfin.Drawing/ImageProcessor.cs568
-rw-r--r--src/Jellyfin.Drawing/Jellyfin.Drawing.csproj (renamed from Emby.Drawing/Emby.Drawing.csproj)8
-rw-r--r--src/Jellyfin.Drawing/NullImageEncoder.cs57
-rw-r--r--src/Jellyfin.Drawing/Properties/AssemblyInfo.cs (renamed from Emby.Drawing/Properties/AssemblyInfo.cs)2
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs34
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaStreamSelectorTests.cs64
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs2
108 files changed, 2401 insertions, 2294 deletions
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 5aebbae4d..7153d4cf5 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3
+ uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
- name: Setup .NET
uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3
with:
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index f62ae853d..5d945c001 100644
--- a/.github/workflows/commands.yml
+++ b/.github/workflows/commands.yml
@@ -24,7 +24,7 @@ jobs:
reactions: '+1'
- name: Checkout the latest code
- uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3
+ uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -51,7 +51,7 @@ jobs:
reactions: eyes
- name: Checkout the latest code
- uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3
+ uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml
index 5dee03ef8..4577ff525 100644
--- a/.github/workflows/openapi.yml
+++ b/.github/workflows/openapi.yml
@@ -14,7 +14,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3
+ uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -25,7 +25,7 @@ jobs:
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
- uses: actions/upload-artifact@83fd05a356d7e2593de66fc9913b3002723633cb # tag=v3
+ uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3
with:
name: openapi-head
retention-days: 14
@@ -39,7 +39,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3
+ uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -57,7 +57,7 @@ jobs:
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
- uses: actions/upload-artifact@83fd05a356d7e2593de66fc9913b3002723633cb # tag=v3
+ uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3
with:
name: openapi-base
retention-days: 14
@@ -76,12 +76,12 @@ jobs:
- openapi-base
steps:
- name: Download openapi-head
- uses: actions/download-artifact@9782bd6a9848b53b110e712e20e42d89988822b7 # tag=v3
+ uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
- uses: actions/download-artifact@9782bd6a9848b53b110e712e20e42d89988822b7 # tag=v3
+ uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
with:
name: openapi-base
path: openapi-base
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 8daaae4d9..ec3c6fd2a 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -27,6 +27,7 @@
- [cvium](https://github.com/cvium)
- [dannymichel](https://github.com/dannymichel)
- [DaveChild](https://github.com/DaveChild)
+ - [DavidFair](https://github.com/DavidFair)
- [Delgan](https://github.com/Delgan)
- [dcrdev](https://github.com/dcrdev)
- [dhartung](https://github.com/dhartung)
@@ -37,6 +38,7 @@
- [DMouse10462](https://github.com/DMouse10462)
- [DrPandemic](https://github.com/DrPandemic)
- [eglia](https://github.com/eglia)
+ - [EgorBakanov](https://github.com/EgorBakanov)
- [EraYaN](https://github.com/EraYaN)
- [escabe](https://github.com/escabe)
- [excelite](https://github.com/excelite)
diff --git a/Emby.Drawing/ImageProcessor.cs b/Emby.Drawing/ImageProcessor.cs
deleted file mode 100644
index 5a49e876a..000000000
--- a/Emby.Drawing/ImageProcessor.cs
+++ /dev/null
@@ -1,569 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Net.Mime;
-using System.Text;
-using System.Threading.Tasks;
-using Jellyfin.Data.Entities;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller;
-using MediaBrowser.Controller.Drawing;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.Model.Drawing;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Net;
-using Microsoft.Extensions.Logging;
-using Photo = MediaBrowser.Controller.Entities.Photo;
-
-namespace Emby.Drawing
-{
- /// <summary>
- /// Class ImageProcessor.
- /// </summary>
- public sealed class ImageProcessor : IImageProcessor, IDisposable
- {
- // Increment this when there's a change requiring caches to be invalidated
- private const char Version = '3';
-
- private static readonly HashSet<string> _transparentImageTypes
- = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
-
- private readonly ILogger<ImageProcessor> _logger;
- private readonly IFileSystem _fileSystem;
- private readonly IServerApplicationPaths _appPaths;
- private readonly IImageEncoder _imageEncoder;
- private readonly IMediaEncoder _mediaEncoder;
-
- private bool _disposed;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="ImageProcessor"/> class.
- /// </summary>
- /// <param name="logger">The logger.</param>
- /// <param name="appPaths">The server application paths.</param>
- /// <param name="fileSystem">The filesystem.</param>
- /// <param name="imageEncoder">The image encoder.</param>
- /// <param name="mediaEncoder">The media encoder.</param>
- public ImageProcessor(
- ILogger<ImageProcessor> logger,
- IServerApplicationPaths appPaths,
- IFileSystem fileSystem,
- IImageEncoder imageEncoder,
- IMediaEncoder mediaEncoder)
- {
- _logger = logger;
- _fileSystem = fileSystem;
- _imageEncoder = imageEncoder;
- _mediaEncoder = mediaEncoder;
- _appPaths = appPaths;
- }
-
- private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
-
- /// <inheritdoc />
- public IReadOnlyCollection<string> SupportedInputFormats =>
- new HashSet<string>(StringComparer.OrdinalIgnoreCase)
- {
- "tiff",
- "tif",
- "jpeg",
- "jpg",
- "png",
- "aiff",
- "cr2",
- "crw",
- "nef",
- "orf",
- "pef",
- "arw",
- "webp",
- "gif",
- "bmp",
- "erf",
- "raf",
- "rw2",
- "nrw",
- "dng",
- "ico",
- "astc",
- "ktx",
- "pkm",
- "wbmp"
- };
-
- /// <inheritdoc />
- public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
-
- /// <inheritdoc />
- public async Task ProcessImage(ImageProcessingOptions options, Stream toStream)
- {
- var file = await ProcessImage(options).ConfigureAwait(false);
- using (var fileStream = AsyncFile.OpenRead(file.Path))
- {
- await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
- }
- }
-
- /// <inheritdoc />
- public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
- => _imageEncoder.SupportedOutputFormats;
-
- /// <inheritdoc />
- public bool SupportsTransparency(string path)
- => _transparentImageTypes.Contains(Path.GetExtension(path));
-
- /// <inheritdoc />
- public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options)
- {
- ItemImageInfo originalImage = options.Image;
- BaseItem item = options.Item;
-
- string originalImagePath = originalImage.Path;
- DateTime dateModified = originalImage.DateModified;
- ImageDimensions? originalImageSize = null;
- if (originalImage.Width > 0 && originalImage.Height > 0)
- {
- originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
- }
-
- var mimeType = MimeTypes.GetMimeType(originalImagePath);
- if (!_imageEncoder.SupportsImageEncoding)
- {
- return (originalImagePath, mimeType, dateModified);
- }
-
- var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
- originalImagePath = supportedImageInfo.Path;
-
- // Original file doesn't exist, or original file is gif.
- if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase))
- {
- return (originalImagePath, mimeType, dateModified);
- }
-
- dateModified = supportedImageInfo.DateModified;
- bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
-
- bool autoOrient = false;
- ImageOrientation? orientation = null;
- if (item is Photo photo)
- {
- if (photo.Orientation.HasValue)
- {
- if (photo.Orientation.Value != ImageOrientation.TopLeft)
- {
- autoOrient = true;
- orientation = photo.Orientation;
- }
- }
- else
- {
- // Orientation unknown, so do it
- autoOrient = true;
- orientation = photo.Orientation;
- }
- }
-
- if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation))
- {
- // Just spit out the original file if all the options are default
- return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
- }
-
- int quality = options.Quality;
-
- ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
- string cacheFilePath = GetCacheFilePath(
- originalImagePath,
- options.Width,
- options.Height,
- options.MaxWidth,
- options.MaxHeight,
- options.FillWidth,
- options.FillHeight,
- quality,
- dateModified,
- outputFormat,
- options.AddPlayedIndicator,
- options.PercentPlayed,
- options.UnplayedCount,
- options.Blur,
- options.BackgroundColor,
- options.ForegroundLayer);
-
- try
- {
- if (!File.Exists(cacheFilePath))
- {
- string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
-
- if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
- {
- return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
- }
- }
-
- return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
- }
- catch (Exception ex)
- {
- // If it fails for whatever reason, return the original image
- _logger.LogError(ex, "Error encoding image");
- return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
- }
- }
-
- private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency)
- {
- var serverFormats = GetSupportedImageOutputFormats();
-
- // Client doesn't care about format, so start with webp if supported
- if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
- {
- return ImageFormat.Webp;
- }
-
- // If transparency is needed and webp isn't supported, than png is the only option
- if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
- {
- return ImageFormat.Png;
- }
-
- foreach (var format in clientSupportedFormats)
- {
- if (serverFormats.Contains(format))
- {
- return format;
- }
- }
-
- // We should never actually get here
- return ImageFormat.Jpg;
- }
-
- private string GetMimeType(ImageFormat format, string path)
- => format switch
- {
- ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"),
- ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"),
- ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"),
- ImageFormat.Png => MimeTypes.GetMimeType("i.png"),
- ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"),
- _ => MimeTypes.GetMimeType(path)
- };
-
- /// <summary>
- /// Gets the cache file path based on a set of parameters.
- /// </summary>
- private string GetCacheFilePath(
- string originalPath,
- int? width,
- int? height,
- int? maxWidth,
- int? maxHeight,
- int? fillWidth,
- int? fillHeight,
- int quality,
- DateTime dateModified,
- ImageFormat format,
- bool addPlayedIndicator,
- double percentPlayed,
- int? unwatchedCount,
- int? blur,
- string backgroundColor,
- string foregroundLayer)
- {
- var filename = new StringBuilder(256);
- filename.Append(originalPath);
-
- filename.Append(",quality=");
- filename.Append(quality);
-
- filename.Append(",datemodified=");
- filename.Append(dateModified.Ticks);
-
- filename.Append(",f=");
- filename.Append(format);
-
- if (width.HasValue)
- {
- filename.Append(",width=");
- filename.Append(width.Value);
- }
-
- if (height.HasValue)
- {
- filename.Append(",height=");
- filename.Append(height.Value);
- }
-
- if (maxWidth.HasValue)
- {
- filename.Append(",maxwidth=");
- filename.Append(maxWidth.Value);
- }
-
- if (maxHeight.HasValue)
- {
- filename.Append(",maxheight=");
- filename.Append(maxHeight.Value);
- }
-
- if (fillWidth.HasValue)
- {
- filename.Append(",fillwidth=");
- filename.Append(fillWidth.Value);
- }
-
- if (fillHeight.HasValue)
- {
- filename.Append(",fillheight=");
- filename.Append(fillHeight.Value);
- }
-
- if (addPlayedIndicator)
- {
- filename.Append(",pl=true");
- }
-
- if (percentPlayed > 0)
- {
- filename.Append(",p=");
- filename.Append(percentPlayed);
- }
-
- if (unwatchedCount.HasValue)
- {
- filename.Append(",p=");
- filename.Append(unwatchedCount.Value);
- }
-
- if (blur.HasValue)
- {
- filename.Append(",blur=");
- filename.Append(blur.Value);
- }
-
- if (!string.IsNullOrEmpty(backgroundColor))
- {
- filename.Append(",b=");
- filename.Append(backgroundColor);
- }
-
- if (!string.IsNullOrEmpty(foregroundLayer))
- {
- filename.Append(",fl=");
- filename.Append(foregroundLayer);
- }
-
- filename.Append(",v=");
- filename.Append(Version);
-
- return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant());
- }
-
- /// <inheritdoc />
- public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
- {
- int width = info.Width;
- int height = info.Height;
-
- if (height > 0 && width > 0)
- {
- return new ImageDimensions(width, height);
- }
-
- string path = info.Path;
- _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
-
- ImageDimensions size = GetImageDimensions(path);
- info.Width = size.Width;
- info.Height = size.Height;
-
- return size;
- }
-
- /// <inheritdoc />
- public ImageDimensions GetImageDimensions(string path)
- => _imageEncoder.GetImageSize(path);
-
- /// <inheritdoc />
- public string GetImageBlurHash(string path)
- {
- var size = GetImageDimensions(path);
- return GetImageBlurHash(path, size);
- }
-
- /// <inheritdoc />
- public string GetImageBlurHash(string path, ImageDimensions imageDimensions)
- {
- if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0)
- {
- return string.Empty;
- }
-
- // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
- // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width.
- // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
- float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height);
- float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width;
-
- int xComp = Math.Min((int)xCompF + 1, 9);
- int yComp = Math.Min((int)yCompF + 1, 9);
-
- return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
- }
-
- /// <inheritdoc />
- public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
- => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
-
- /// <inheritdoc />
- public string GetImageCacheTag(BaseItem item, ChapterInfo chapter)
- {
- return GetImageCacheTag(item, new ItemImageInfo
- {
- Path = chapter.ImagePath,
- Type = ImageType.Chapter,
- DateModified = chapter.ImageDateModified
- });
- }
-
- /// <inheritdoc />
- public string? GetImageCacheTag(User user)
- {
- if (user.ProfileImage is null)
- {
- return null;
- }
-
- return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
- .ToString("N", CultureInfo.InvariantCulture);
- }
-
- private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
- {
- var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString();
-
- // These are just jpg files renamed as tbn
- if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
- {
- return Task.FromResult((originalImagePath, dateModified));
- }
-
- // TODO _mediaEncoder.ConvertImage is not implemented
- // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
- // {
- // try
- // {
- // string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
- //
- // string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
- // var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
- //
- // var file = _fileSystem.GetFileInfo(outputPath);
- // if (!file.Exists)
- // {
- // await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
- // dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
- // }
- // else
- // {
- // dateModified = file.LastWriteTimeUtc;
- // }
- //
- // originalImagePath = outputPath;
- // }
- // catch (Exception ex)
- // {
- // _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
- // }
- // }
-
- return Task.FromResult((originalImagePath, dateModified));
- }
-
- /// <summary>
- /// Gets the cache path.
- /// </summary>
- /// <param name="path">The path.</param>
- /// <param name="uniqueName">Name of the unique.</param>
- /// <param name="fileExtension">The file extension.</param>
- /// <returns>System.String.</returns>
- /// <exception cref="ArgumentNullException">
- /// path
- /// or
- /// uniqueName
- /// or
- /// fileExtension.
- /// </exception>
- public string GetCachePath(string path, string uniqueName, string fileExtension)
- {
- ArgumentException.ThrowIfNullOrEmpty(path);
- ArgumentException.ThrowIfNullOrEmpty(uniqueName);
- ArgumentException.ThrowIfNullOrEmpty(fileExtension);
-
- var filename = uniqueName.GetMD5() + fileExtension;
-
- return GetCachePath(path, filename);
- }
-
- /// <summary>
- /// Gets the cache path.
- /// </summary>
- /// <param name="path">The path.</param>
- /// <param name="filename">The filename.</param>
- /// <returns>System.String.</returns>
- /// <exception cref="ArgumentNullException">
- /// path
- /// or
- /// filename.
- /// </exception>
- public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
- {
- if (path.IsEmpty)
- {
- throw new ArgumentException("Path can't be empty.", nameof(path));
- }
-
- if (filename.IsEmpty)
- {
- throw new ArgumentException("Filename can't be empty.", nameof(filename));
- }
-
- var prefix = filename.Slice(0, 1);
-
- return Path.Join(path, prefix, filename);
- }
-
- /// <inheritdoc />
- public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
- {
- _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
-
- _imageEncoder.CreateImageCollage(options, libraryName);
-
- _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
- }
-
- /// <inheritdoc />
- public void Dispose()
- {
- if (_disposed)
- {
- return;
- }
-
- if (_imageEncoder is IDisposable disposable)
- {
- disposable.Dispose();
- }
-
- _disposed = true;
- }
- }
-}
diff --git a/Emby.Drawing/NullImageEncoder.cs b/Emby.Drawing/NullImageEncoder.cs
deleted file mode 100644
index d0a26b713..000000000
--- a/Emby.Drawing/NullImageEncoder.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System;
-using System.Collections.Generic;
-using MediaBrowser.Controller.Drawing;
-using MediaBrowser.Model.Drawing;
-
-namespace Emby.Drawing
-{
- /// <summary>
- /// A fallback implementation of <see cref="IImageEncoder" />.
- /// </summary>
- public class NullImageEncoder : IImageEncoder
- {
- /// <inheritdoc />
- public IReadOnlyCollection<string> SupportedInputFormats
- => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" };
-
- /// <inheritdoc />
- public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
- => new HashSet<ImageFormat>() { ImageFormat.Jpg, ImageFormat.Png };
-
- /// <inheritdoc />
- public string Name => "Null Image Encoder";
-
- /// <inheritdoc />
- public bool SupportsImageCollageCreation => false;
-
- /// <inheritdoc />
- public bool SupportsImageEncoding => false;
-
- /// <inheritdoc />
- public ImageDimensions GetImageSize(string path)
- => throw new NotImplementedException();
-
- /// <inheritdoc />
- public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
- {
- throw new NotImplementedException();
- }
-
- /// <inheritdoc />
- public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
- {
- throw new NotImplementedException();
- }
-
- /// <inheritdoc />
- public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
- {
- throw new NotImplementedException();
- }
-
- /// <inheritdoc />
- public string GetImageBlurHash(int xComp, int yComp, string path)
- {
- throw new NotImplementedException();
- }
- }
-}
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 5db3748bf..7b3d07dfc 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -18,7 +18,6 @@ using System.Threading.Tasks;
using Emby.Dlna;
using Emby.Dlna.Main;
using Emby.Dlna.Ssdp;
-using Emby.Drawing;
using Emby.Naming.Common;
using Emby.Notifications;
using Emby.Photos;
@@ -45,6 +44,7 @@ using Emby.Server.Implementations.SyncPlay;
using Emby.Server.Implementations.TV;
using Emby.Server.Implementations.Updates;
using Jellyfin.Api.Helpers;
+using Jellyfin.Drawing;
using Jellyfin.MediaEncoding.Hls.Playlist;
using Jellyfin.Networking.Configuration;
using Jellyfin.Networking.Manager;
diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
index 9bdc4e5c8..763ff77f1 100644
--- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs
+++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
@@ -2401,13 +2401,17 @@ namespace Emby.Server.Implementations.Data
var builder = new StringBuilder();
builder.Append('(');
- if (string.IsNullOrEmpty(item.OfficialRating))
+ if (item.InheritedParentalRatingValue == 0)
{
- builder.Append("(OfficialRating is null * 10)");
+ builder.Append("((InheritedParentalRatingValue=0) * 10)");
}
else
{
- builder.Append("(OfficialRating=@ItemOfficialRating * 10)");
+ builder.Append(
+ @"(SELECT CASE WHEN InheritedParentalRatingValue=0
+ THEN 0
+ ELSE 10.0 / (1.0 + ABS(InheritedParentalRatingValue - @InheritedParentalRatingValue))
+ END)");
}
if (item.ProductionYear.HasValue)
@@ -2521,6 +2525,11 @@ namespace Emby.Server.Implementations.Data
{
statement.TryBind("@SimilarItemId", item.Id);
}
+
+ if (commandText.Contains("@InheritedParentalRatingValue", StringComparison.OrdinalIgnoreCase))
+ {
+ statement.TryBind("@InheritedParentalRatingValue", item.InheritedParentalRatingValue);
+ }
}
private string GetJoinUserDataText(InternalItemsQuery query)
diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
index f46affc73..7accc3b8b 100644
--- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj
+++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
@@ -18,7 +18,7 @@
<ProjectReference Include="..\Emby.Dlna\Emby.Dlna.csproj" />
<ProjectReference Include="..\MediaBrowser.LocalMetadata\MediaBrowser.LocalMetadata.csproj" />
<ProjectReference Include="..\Emby.Photos\Emby.Photos.csproj" />
- <ProjectReference Include="..\Emby.Drawing\Emby.Drawing.csproj" />
+ <ProjectReference Include="..\src\Jellyfin.Drawing\Jellyfin.Drawing.csproj" />
<ProjectReference Include="..\MediaBrowser.MediaEncoding\MediaBrowser.MediaEncoding.csproj" />
</ItemGroup>
@@ -29,7 +29,7 @@
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.1" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.2" />
<PackageReference Include="Mono.Nat" Version="3.0.4" />
<PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.0" />
<PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" />
diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
index 74c53b2da..6aef87c52 100644
--- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs
+++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
@@ -89,17 +89,7 @@ namespace Emby.Server.Implementations.Library
// Give some preference to external text subs for better performance
return streams
.Where(i => i.Type == type)
- .OrderBy(i =>
- {
- var index = languagePreferences.FindIndex(x => string.Equals(x, i.Language, StringComparison.OrdinalIgnoreCase));
-
- return index == -1 ? 100 : index;
- })
- .ThenBy(i => GetBooleanOrderBy(i.IsDefault))
- .ThenBy(i => GetBooleanOrderBy(i.SupportsExternalStream))
- .ThenBy(i => GetBooleanOrderBy(i.IsTextSubtitleStream))
- .ThenBy(i => GetBooleanOrderBy(i.IsExternal))
- .ThenBy(i => i.Index);
+ .OrderByDescending(i => GetStreamScore(i, languagePreferences));
}
public static void SetSubtitleStreamScores(
@@ -113,9 +103,9 @@ namespace Emby.Server.Implementations.Library
return;
}
- var sortedStreams = GetSortedStreams(streams, MediaStreamType.Subtitle, preferredLanguages);
+ var sortedStreams = GetSortedStreams(streams, MediaStreamType.Subtitle, preferredLanguages).ToList();
- var filteredStreams = new List<MediaStream>();
+ List<MediaStream>? filteredStreams = null;
if (mode == SubtitlePlaybackMode.Default)
{
@@ -144,46 +134,26 @@ namespace Emby.Server.Implementations.Library
}
// load forced subs if we have found no suitable full subtitles
- var iterStreams = filteredStreams.Count == 0
+ var iterStreams = filteredStreams is null || filteredStreams.Count == 0
? sortedStreams.Where(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
: filteredStreams;
foreach (var stream in iterStreams)
{
- stream.Score = GetSubtitleScore(stream, preferredLanguages);
+ stream.Score = GetStreamScore(stream, preferredLanguages);
}
}
- private static int GetSubtitleScore(MediaStream stream, IReadOnlyList<string> languagePreferences)
+ internal static int GetStreamScore(MediaStream stream, IReadOnlyList<string> languagePreferences)
{
- var values = new List<int>();
-
var index = languagePreferences.FindIndex(x => string.Equals(x, stream.Language, StringComparison.OrdinalIgnoreCase));
-
- values.Add(index == -1 ? 0 : 100 - index);
-
- values.Add(stream.IsForced ? 1 : 0);
- values.Add(stream.IsDefault ? 1 : 0);
- values.Add(stream.SupportsExternalStream ? 1 : 0);
- values.Add(stream.IsTextSubtitleStream ? 1 : 0);
- values.Add(stream.IsExternal ? 1 : 0);
-
- values.Reverse();
- var scale = 1;
- var score = 0;
-
- foreach (var value in values)
- {
- score += scale * (value + 1);
- scale *= 10;
- }
-
+ var score = index == -1 ? 1 : 101 - index;
+ score = (score * 10) + (stream.IsForced ? 2 : 1);
+ score = (score * 10) + (stream.IsDefault ? 2 : 1);
+ score = (score * 10) + (stream.SupportsExternalStream ? 2 : 1);
+ score = (score * 10) + (stream.IsTextSubtitleStream ? 2 : 1);
+ score = (score * 10) + (stream.IsExternal ? 2 : 1);
return score;
}
-
- private static int GetBooleanOrderBy(bool value)
- {
- return value ? 0 : 1;
- }
}
}
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
index 8f5fa8694..8edd8f66a 100644
--- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
@@ -1814,21 +1814,29 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
program.AddGenre("News");
}
- if (timer.IsProgramSeries)
- {
- await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
- await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
- }
- else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
+ var config = GetConfiguration();
+
+ if (config.SaveRecordingNFO)
{
- await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false);
+ if (timer.IsProgramSeries)
+ {
+ await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
+ await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
+ }
+ else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
+ {
+ await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false);
+ }
+ else
+ {
+ await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
+ }
}
- else
+
+ if (config.SaveRecordingImages)
{
- await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
+ await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
}
-
- await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
}
catch (Exception ex)
{
diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json
index f356c98a9..9fbf364ef 100644
--- a/Emby.Server.Implementations/Localization/Core/af.json
+++ b/Emby.Server.Implementations/Localization/Core/af.json
@@ -122,5 +122,6 @@
"TaskOptimizeDatabase": "Optimaliseer databasis",
"TaskKeyframeExtractorDescription": "Haal keyframes vanuit video lêers om meer presiese HLS afspeellyste te maak. Dit kan lank duur.",
"TaskKeyframeExtractor": "Keyframe Ekstraktor",
- "External": "Ekstern"
+ "External": "Ekstern",
+ "HearingImpaired": "gehoorgestremd"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json
index 4508363b0..93d50e6e3 100644
--- a/Emby.Server.Implementations/Localization/Core/ar.json
+++ b/Emby.Server.Implementations/Localization/Core/ar.json
@@ -3,9 +3,9 @@
"AppDeviceValues": "تطبيق: {0}, جهاز: {1}",
"Application": "تطبيق",
"Artists": "الفنانين",
- "AuthenticationSucceededWithUserName": "تمت مصادقة {0} بنجاح",
+ "AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}",
"Books": "الكتب",
- "CameraImageUploadedFrom": "صورة كاميرا جديدة تم رفعها من {0}",
+ "CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}",
"Channels": "القنوات",
"ChapterNameValue": "الفصل {0}",
"Collections": "التجميعات",
@@ -16,7 +16,7 @@
"Folders": "المجلدات",
"Genres": "التصنيفات",
"HeaderAlbumArtists": "فناني الألبوم",
- "HeaderContinueWatching": "استمر بالمشاهدة",
+ "HeaderContinueWatching": "استئناف المشاهدة",
"HeaderFavoriteAlbums": "الألبومات المفضلة",
"HeaderFavoriteArtists": "الفنانون المفضلون",
"HeaderFavoriteEpisodes": "الحلقات المفضلة",
@@ -27,15 +27,15 @@
"HeaderRecordingGroups": "مجموعات التسجيل",
"HomeVideos": "الفيديوهات الشخصية",
"Inherit": "توريث",
- "ItemAddedWithName": "تم إضافة {0} للمكتبة",
- "ItemRemovedWithName": "تم إزالة {0} من المكتبة",
+ "ItemAddedWithName": "أُضيف {0} للمكتبة",
+ "ItemRemovedWithName": "أُزيل {0} من المكتبة",
"LabelIpAddressValue": "عنوان الآي بي: {0}",
"LabelRunningTimeValue": "مدة التشغيل: {0}",
"Latest": "أحدث",
- "MessageApplicationUpdated": "لقد تم تحديث خادم Jellyfin",
- "MessageApplicationUpdatedTo": "تم تحديث خادم Jellyfin الى {0}",
- "MessageNamedServerConfigurationUpdatedWithValue": "تم تحديث إعدادات الخادم في قسم {0}",
- "MessageServerConfigurationUpdated": "تم تحديث إعدادات الخادم",
+ "MessageApplicationUpdated": "حُدث خادم Jellyfin",
+ "MessageApplicationUpdatedTo": "حُدث خادم Jellyfin إلى {0}",
+ "MessageNamedServerConfigurationUpdatedWithValue": "حُدثت إعدادات الخادم في قسم {0}",
+ "MessageServerConfigurationUpdated": "حُدثت إعدادات الخادم",
"MixedContent": "محتوى مختلط",
"Movies": "الأفلام",
"Music": "الموسيقى",
@@ -45,14 +45,14 @@
"NameSeasonUnknown": "الموسم غير معروف",
"NewVersionIsAvailable": "نسخة جديدة من خادم Jellyfin متوفرة للتحميل.",
"NotificationOptionApplicationUpdateAvailable": "يوجد تحديث للتطبيق",
- "NotificationOptionApplicationUpdateInstalled": "تم تحديث التطبيق",
+ "NotificationOptionApplicationUpdateInstalled": "نُصب تحديث التطبيق",
"NotificationOptionAudioPlayback": "بدأ تشغيل المقطع الصوتي",
- "NotificationOptionAudioPlaybackStopped": "تم إيقاف تشغيل المقطع الصوتي",
- "NotificationOptionCameraImageUploaded": "تم رفع صورة الكاميرا",
+ "NotificationOptionAudioPlaybackStopped": "أُوقف تشغيل المقطع الصوتي",
+ "NotificationOptionCameraImageUploaded": "رُفعت صورة الكاميرا",
"NotificationOptionInstallationFailed": "فشل في التثبيت",
- "NotificationOptionNewLibraryContent": "تم إضافة محتوى جديد",
+ "NotificationOptionNewLibraryContent": "أُضيف محتوى جديدا",
"NotificationOptionPluginError": "فشل في الملحق",
- "NotificationOptionPluginInstalled": "تم تثبيت الملحق",
+ "NotificationOptionPluginInstalled": "ثُبتت المكونات الإضافية",
"NotificationOptionPluginUninstalled": "تمت إزالة الملحق",
"NotificationOptionPluginUpdateInstalled": "تم تثبيت تحديثات الملحق",
"NotificationOptionServerRestartRequired": "يجب إعادة تشغيل الخادم",
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index 8e9287af4..c6e2244ca 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -15,7 +15,7 @@
"Favorites": "Αγαπημένα",
"Folders": "Φάκελοι",
"Genres": "Είδη",
- "HeaderAlbumArtists": "Δισκογραφικοί καλλιτέχνες",
+ "HeaderAlbumArtists": "Καλλιτέχνες άλμπουμ",
"HeaderContinueWatching": "Συνεχίστε την παρακολούθηση",
"HeaderFavoriteAlbums": "Αγαπημένα Άλμπουμ",
"HeaderFavoriteArtists": "Αγαπημένοι Καλλιτέχνες",
@@ -24,8 +24,8 @@
"HeaderFavoriteSongs": "Αγαπημένα Τραγούδια",
"HeaderLiveTV": "Ζωντανή Τηλεόραση",
"HeaderNextUp": "Επόμενο",
- "HeaderRecordingGroups": "Μουσικά Συγκροτήματα",
- "HomeVideos": "Προσωπικά βίντεο",
+ "HeaderRecordingGroups": "Ομάδες Ηχογράφησης",
+ "HomeVideos": "Προσωπικά Βίντεο",
"Inherit": "Κληρονόμηση",
"ItemAddedWithName": "{0} προστέθηκε στη βιβλιοθήκη",
"ItemRemovedWithName": "{0} διαγράφηκε από τη βιβλιοθήκη",
@@ -51,10 +51,10 @@
"NotificationOptionCameraImageUploaded": "Μεταφορτώθηκε φωτογραφία απο κάμερα",
"NotificationOptionInstallationFailed": "Αποτυχία εγκατάστασης",
"NotificationOptionNewLibraryContent": "Προστέθηκε νέο περιεχόμενο",
- "NotificationOptionPluginError": "Αποτυχία του plugin",
- "NotificationOptionPluginInstalled": "Το plugin εγκαταστάθηκε",
- "NotificationOptionPluginUninstalled": "Το plugin απεγκαταστάθηκε",
- "NotificationOptionPluginUpdateInstalled": "Η αναβάθμιση του plugin εγκαταστάθηκε",
+ "NotificationOptionPluginError": "Αποτυχία του πρόσθετου",
+ "NotificationOptionPluginInstalled": "Το πρόσθετο εγκαταστάθηκε",
+ "NotificationOptionPluginUninstalled": "Το πρόσθετο απεγκαταστάθηκε",
+ "NotificationOptionPluginUpdateInstalled": "Η αναβάθμιση του πρόσθετου εγκαταστάθηκε",
"NotificationOptionServerRestartRequired": "Ο διακομιστής χρειάζεται επανεκκίνηση",
"NotificationOptionTaskFailed": "Αποτυχία προγραμματισμένης εργασίας",
"NotificationOptionUserLockedOut": "Ο χρήστης αποκλείστηκε",
@@ -66,7 +66,7 @@
"PluginInstalledWithName": "{0} εγκαταστήθηκε",
"PluginUninstalledWithName": "{0} έχει απεγκατασταθεί",
"PluginUpdatedWithName": "{0} έχει αναβαθμιστεί",
- "ProviderValue": "Provider: {0}",
+ "ProviderValue": "Πάροχος: {0}",
"ScheduledTaskFailedWithName": "{0} αποτυχία",
"ScheduledTaskStartedWithName": "{0} ξεκίνησε",
"ServerNameNeedsToBeRestarted": "{0} χρειάζεται επανεκκίνηση",
@@ -79,7 +79,7 @@
"System": "Σύστημα",
"TvShows": "Τηλεοπτικές Σειρές",
"User": "Χρήστης",
- "UserCreatedWithName": "Δημιουργήθηκε ο χρήστης {0}",
+ "UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε",
"UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί",
"UserDownloadingItemWithValues": "{0} κατεβάζει {1}",
"UserLockedOutWithName": "Ο χρήστης {0} αποκλείστηκε",
@@ -93,29 +93,29 @@
"ValueSpecialEpisodeName": "Σπέσιαλ - {0}",
"VersionNumber": "Έκδοση {0}",
"TaskRefreshPeople": "Ανανέωση Ατόμων",
- "TaskCleanLogsDescription": "Διαγράφει τα αρχεία καταγραφής που είναι άνω των {0} ημερών.",
- "TaskCleanLogs": "Καθαρισμός Καταλόγου Καταγραφής",
- "TaskRefreshLibraryDescription": "Σαρώνει την βιβλιοθήκη πολυμέσων σας για νέα αρχεία και αναζωογονεί τα μεταδεδομένα.",
+ "TaskCleanLogsDescription": "Διαγράφει αρχεία καταγραφής που είναι πάνω από {0} ημέρες.",
+ "TaskCleanLogs": "Εκκαθάριση Καταλόγου Καταγραφής",
+ "TaskRefreshLibraryDescription": "Σαρώνει την βιβλιοθήκη πολυμέσων σας για νέα αρχεία και ανανεώνει τα μεταδεδομένα.",
"TaskRefreshLibrary": "Βιβλιοθήκη Σάρωσης Πολυμέσων",
- "TaskRefreshChapterImagesDescription": "Δημιουργεί μικρογραφίες για βίντεο με κεφάλαια.",
+ "TaskRefreshChapterImagesDescription": "Δημιουργεί μικρογραφίες για βίντεο που έχουν κεφάλαια.",
"TaskRefreshChapterImages": "Εξαγωγή Εικόνων Κεφαλαίου",
- "TaskCleanCacheDescription": "Τα διαγραμμένα αρχεία προσωρινής μνήμης που δεν χρειάζονται πλέον από το σύστημα.",
+ "TaskCleanCacheDescription": "Διαγράφει αρχεία προσωρινής μνήμης που δεν χρειάζονται πλέον το σύστημα.",
"TaskCleanCache": "Καθαρισμός Καταλόγου Προσωρινής Μνήμης",
"TasksChannelsCategory": "Κανάλια Διαδικτύου",
"TasksApplicationCategory": "Εφαρμογή",
"TasksLibraryCategory": "Βιβλιοθήκη",
"TasksMaintenanceCategory": "Συντήρηση",
- "TaskDownloadMissingSubtitlesDescription": "Αναζητήσεις στο διαδίκτυο όπου λείπουν υπότιτλους με βάση τη διαμόρφωση μεταδεδομένων.",
+ "TaskDownloadMissingSubtitlesDescription": "Ψάχνει στο διαδίκτυο για υπότιτλους που λείπουν με βάση τη διαμόρφωση μεταδεδομένων.",
"TaskDownloadMissingSubtitles": "Λήψη υπότιτλων που λείπουν",
"TaskRefreshChannelsDescription": "Ανανεώνει τις πληροφορίες καναλιού στο διαδικτύου.",
"TaskRefreshChannels": "Ανανέωση Καναλιών",
- "TaskCleanTranscodeDescription": "Διαγράφει αρχείου διακωδικοποιητή περισσότερο από μία ημέρα.",
- "TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή",
- "TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.",
- "TaskUpdatePlugins": "Ενημέρωση Προσθηκών",
- "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας.",
+ "TaskCleanTranscodeDescription": "Διαγράφει αρχεία διακωδικοποίησης άνω της μίας ημέρας.",
+ "TaskCleanTranscode": "Εκκαθάριση Kαταλόγου Διακωδικοποίησης",
+ "TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τα πρόσθετα που έχουν ρυθμιστεί για αυτόματη ενημέρωση.",
+ "TaskUpdatePlugins": "Ενημέρωση Πρόσθετων",
+ "TaskRefreshPeopleDescription": "Ενημερώνει τα μεταδεδομένα για ηθοποιούς και σκηνοθέτες στη βιβλιοθήκη πολυμέσων σας.",
"TaskCleanActivityLogDescription": "Διαγράφει καταχωρήσεις απο το αρχείο καταγραφής παλαιότερες από την επιλεγμένη ηλικία.",
- "TaskCleanActivityLog": "Καθαρό Αρχείο Καταγραφής Δραστηριοτήτων",
+ "TaskCleanActivityLog": "Εκκαθάριση Αρχείου Καταγραφής Δραστηριοτήτων",
"Undefined": "Απροσδιόριστο",
"Forced": "Εξαναγκασμένο",
"Default": "Προεπιλογή",
diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json
index d657ac7b6..e91084f92 100644
--- a/Emby.Server.Implementations/Localization/Core/eu.json
+++ b/Emby.Server.Implementations/Localization/Core/eu.json
@@ -100,7 +100,7 @@
"ItemRemovedWithName": "{0} liburutegitik ezabatu da",
"ItemAddedWithName": "{0} liburutegira gehitu da",
"HomeVideos": "Etxeko bideoak",
- "HeaderNextUp": "Hurrengoa",
+ "HeaderNextUp": "Nobedadeak",
"HeaderLiveTV": "Zuzeneko TB",
"HeaderFavoriteSongs": "Gogoko abestiak",
"HeaderFavoriteShows": "Gogoko showak",
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index d90d705b2..7f616c35a 100644
--- a/Emby.Server.Implementations/Localization/Core/ja.json
+++ b/Emby.Server.Implementations/Localization/Core/ja.json
@@ -122,5 +122,6 @@
"TaskOptimizeDatabase": "データベースの最適化",
"TaskKeyframeExtractorDescription": "より正確なHLSプレイリストを作成するため、動画ファイルからキーフレームを抽出する。この処理には時間がかかる場合があります。",
"TaskKeyframeExtractor": "キーフレーム抽出",
- "External": "外部"
+ "External": "外部",
+ "HearingImpaired": "聴覚障害の方"
}
diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt
index 66fba3330..b55c0fa33 100644
--- a/Emby.Server.Implementations/Localization/iso6392.txt
+++ b/Emby.Server.Implementations/Localization/iso6392.txt
@@ -77,6 +77,7 @@ chb|||Chibcha|chibcha
che||ce|Chechen|tchétchène
chg|||Chagatai|djaghataï
chi|zho|zh|Chinese|chinois
+chi|zho|ze|Chinese; Bilingual|chinois
chi|zho|zh-tw|Chinese; Traditional|chinois
chi|zho|zh-hk|Chinese; Hong Kong|chinois
chk|||Chuukese|chuuk
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 2f60d01a9..afa3721b8 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -95,12 +95,6 @@ namespace Emby.Server.Implementations.Session
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
}
- /// <inheritdoc />
- public event EventHandler<GenericEventArgs<AuthenticationRequest>> AuthenticationFailed;
-
- /// <inheritdoc />
- public event EventHandler<GenericEventArgs<AuthenticationResult>> AuthenticationSucceeded;
-
/// <summary>
/// Occurs when playback has started.
/// </summary>
@@ -1468,7 +1462,7 @@ namespace Emby.Server.Implementations.Session
if (user is null)
{
- AuthenticationFailed?.Invoke(this, new GenericEventArgs<AuthenticationRequest>(request));
+ await _eventManager.PublishAsync(new GenericEventArgs<AuthenticationRequest>(request)).ConfigureAwait(false);
throw new AuthenticationException("Invalid username or password entered.");
}
@@ -1504,8 +1498,7 @@ namespace Emby.Server.Implementations.Session
ServerId = _appHost.SystemId
};
- AuthenticationSucceeded?.Invoke(this, new GenericEventArgs<AuthenticationResult>(returnResult));
-
+ await _eventManager.PublishAsync(new GenericEventArgs<AuthenticationResult>(returnResult)).ConfigureAwait(false);
return returnResult;
}
diff --git a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs
index 58552d847..fbe68b6b9 100644
--- a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs
+++ b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs
@@ -25,6 +25,6 @@ namespace Jellyfin.Api.Attributes
/// Gets the configured content types.
/// </summary>
/// <returns>the configured content types.</returns>
- public string[] GetContentTypes() => _contentTypes;
+ public string[] ContentTypes => _contentTypes;
}
}
diff --git a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs
index 2bf77d729..d8e4141ac 100644
--- a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs
+++ b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs
@@ -25,6 +25,6 @@ namespace Jellyfin.Api.Attributes
/// Gets the configured content types.
/// </summary>
/// <returns>the configured content types.</returns>
- public string[] GetContentTypes() => _contentTypes;
+ public string[] ContentTypes => _contentTypes;
}
}
diff --git a/Jellyfin.Api/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs
index 0c63d24b7..e327831fe 100644
--- a/Jellyfin.Api/BaseJellyfinApiController.cs
+++ b/Jellyfin.Api/BaseJellyfinApiController.cs
@@ -23,24 +23,6 @@ namespace Jellyfin.Api
/// <param name="value">The value to return.</param>
/// <typeparam name="T">The type to return.</typeparam>
/// <returns>The <see cref="ActionResult{T}"/>.</returns>
- protected ActionResult<IEnumerable<T>> Ok<T>(List<T> value)
- => new OkResult<IEnumerable<T>>(value);
-
- /// <summary>
- /// Create a new <see cref="OkResult{T}"/>.
- /// </summary>
- /// <param name="value">The value to return.</param>
- /// <typeparam name="T">The type to return.</typeparam>
- /// <returns>The <see cref="ActionResult{T}"/>.</returns>
- protected ActionResult<IEnumerable<T>> Ok<T>(IReadOnlyList<T> value)
- => new OkResult<IEnumerable<T>>(value);
-
- /// <summary>
- /// Create a new <see cref="OkResult{T}"/>.
- /// </summary>
- /// <param name="value">The value to return.</param>
- /// <typeparam name="T">The type to return.</typeparam>
- /// <returns>The <see cref="ActionResult{T}"/>.</returns>
protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value)
=> new OkResult<IEnumerable<T>?>(value);
diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs
index 593846adc..024a15349 100644
--- a/Jellyfin.Api/Controllers/ApiKeyController.cs
+++ b/Jellyfin.Api/Controllers/ApiKeyController.cs
@@ -36,7 +36,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys()
{
- var keys = await _authenticationManager.GetApiKeys();
+ var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false);
return new QueryResult<AuthenticationInfo>(keys);
}
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index e0c5bcc84..ba9a57f1d 100644
--- a/Jellyfin.Api/Controllers/DynamicHlsController.cs
+++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs
@@ -1705,11 +1705,12 @@ namespace Jellyfin.Api.Controllers
return audioTranscodeParams;
}
- // flac and opus are experimental in mp4 muxer
+ // dts, flac and opus are experimental in mp4 muxer
var strictArgs = string.Empty;
if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)
- || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase))
{
strictArgs = " -strict -2";
}
diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs
index 49342ad5c..534667c8c 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -106,24 +106,26 @@ namespace Jellyfin.Api.Controllers
}
var user = _userManager.GetUserById(userId);
- await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-
- // Handle image/png; charset=utf-8
- var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
- var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
- if (user.ProfileImage is not null)
+ var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+ await using (memoryStream.ConfigureAwait(false))
{
- await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
- }
+ // Handle image/png; charset=utf-8
+ var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+ var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+ if (user.ProfileImage is not null)
+ {
+ await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+ }
- user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+ user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
- await _providerManager
- .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
- .ConfigureAwait(false);
- await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+ await _providerManager
+ .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+ .ConfigureAwait(false);
+ await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
- return NoContent();
+ return NoContent();
+ }
}
/// <summary>
@@ -153,24 +155,26 @@ namespace Jellyfin.Api.Controllers
}
var user = _userManager.GetUserById(userId);
- await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-
- // Handle image/png; charset=utf-8
- var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
- var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
- if (user.ProfileImage is not null)
+ var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+ await using (memoryStream.ConfigureAwait(false))
{
- await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
- }
+ // Handle image/png; charset=utf-8
+ var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+ var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+ if (user.ProfileImage is not null)
+ {
+ await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+ }
- user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+ user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
- await _providerManager
- .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
- .ConfigureAwait(false);
- await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+ await _providerManager
+ .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+ .ConfigureAwait(false);
+ await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
- return NoContent();
+ return NoContent();
+ }
}
/// <summary>
@@ -341,14 +345,16 @@ namespace Jellyfin.Api.Controllers
return NotFound();
}
- await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-
- // Handle image/png; charset=utf-8
- var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
- await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
- await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+ var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+ await using (memoryStream.ConfigureAwait(false))
+ {
+ // Handle image/png; charset=utf-8
+ var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+ await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+ await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
- return NoContent();
+ return NoContent();
+ }
}
/// <summary>
@@ -377,14 +383,16 @@ namespace Jellyfin.Api.Controllers
return NotFound();
}
- await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-
- // Handle image/png; charset=utf-8
- var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
- await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
- await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+ var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+ await using (memoryStream.ConfigureAwait(false))
+ {
+ // Handle image/png; charset=utf-8
+ var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+ await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+ await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
- return NoContent();
+ return NoContent();
+ }
}
/// <summary>
@@ -1788,32 +1796,35 @@ namespace Jellyfin.Api.Controllers
[AcceptsImageFile]
public async Task<ActionResult> UploadCustomSplashscreen()
{
- await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+ var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+ await using (memoryStream.ConfigureAwait(false))
+ {
+ var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
- var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
+ if (!mimeType.HasValue)
+ {
+ return BadRequest("Error reading mimetype from uploaded image");
+ }
- if (!mimeType.HasValue)
- {
- return BadRequest("Error reading mimetype from uploaded image");
- }
+ var extension = MimeTypes.ToExtension(mimeType.Value);
+ if (string.IsNullOrEmpty(extension))
+ {
+ return BadRequest("Error converting mimetype to an image extension");
+ }
- var extension = MimeTypes.ToExtension(mimeType.Value);
- if (string.IsNullOrEmpty(extension))
- {
- return BadRequest("Error converting mimetype to an image extension");
- }
+ var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
+ var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+ brandingOptions.SplashscreenLocation = filePath;
+ _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
- var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
- var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
- brandingOptions.SplashscreenLocation = filePath;
- _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
+ var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
+ await using (fs.ConfigureAwait(false))
+ {
+ await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
+ }
- await using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous))
- {
- await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
+ return NoContent();
}
-
- return NoContent();
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs
index 94710d78f..5228e0bab 100644
--- a/Jellyfin.Api/Controllers/LiveTvController.cs
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -1011,10 +1011,9 @@ namespace Jellyfin.Api.Controllers
{
if (!string.IsNullOrEmpty(pw))
{
- using var sha = SHA1.Create();
// TODO: remove ToLower when Convert.ToHexString supports lowercase
// Schedules Direct requires the hex to be lowercase
- listingsProviderInfo.Password = Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
+ listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
}
return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
index 0aa7c2ac9..10f967dcd 100644
--- a/Jellyfin.Api/Controllers/PackageController.cs
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -145,7 +145,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories()
{
- return _serverConfigurationManager.Configuration.PluginRepositories;
+ return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable());
}
/// <summary>
@@ -157,7 +157,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Repositories")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SetRepositories([FromBody, Required] List<RepositoryInfo> repositoryInfos)
+ public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos)
{
_serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos;
_serverConfigurationManager.SaveConfiguration();
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
index 6a729b237..b8a09990a 100644
--- a/Jellyfin.Api/Controllers/PluginsController.cs
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.Json;
@@ -143,7 +144,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId)
{
// If no version is given, return the current instance.
- var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId));
+ var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)).ToList();
// Select the un-instanced one first.
var plugin = plugins.FirstOrDefault(p => p.Instance is null) ?? plugins.OrderBy(p => p.Manifest.Status).FirstOrDefault();
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index ff9bd095b..c3ce1868e 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -236,14 +236,17 @@ namespace Jellyfin.Api.Controllers
if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
{
- await using Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
- using var reader = new StreamReader(stream);
+ Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ using var reader = new StreamReader(stream);
- var text = await reader.ReadToEndAsync().ConfigureAwait(false);
+ var text = await reader.ReadToEndAsync().ConfigureAwait(false);
- text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal);
+ text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal);
- return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
+ return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
+ }
}
return File(
@@ -403,19 +406,22 @@ namespace Jellyfin.Api.Controllers
{
var video = (Video)_libraryManager.GetItemById(itemId);
var data = Convert.FromBase64String(body.Data);
- await using var memoryStream = new MemoryStream(data);
- await _subtitleManager.UploadSubtitle(
- video,
- new SubtitleResponse
- {
- Format = body.Format,
- Language = body.Language,
- IsForced = body.IsForced,
- Stream = memoryStream
- }).ConfigureAwait(false);
- _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ var memoryStream = new MemoryStream(data, 0, data.Length, false, true);
+ await using (memoryStream.ConfigureAwait(false))
+ {
+ await _subtitleManager.UploadSubtitle(
+ video,
+ new SubtitleResponse
+ {
+ Format = body.Format,
+ Language = body.Language,
+ IsForced = body.IsForced,
+ Stream = memoryStream
+ }).ConfigureAwait(false);
+ _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
- return NoContent();
+ return NoContent();
+ }
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs
index e194fc556..99347246e 100644
--- a/Jellyfin.Api/Controllers/SyncPlayController.cs
+++ b/Jellyfin.Api/Controllers/SyncPlayController.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
@@ -107,7 +108,7 @@ namespace Jellyfin.Api.Controllers
{
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
var syncPlayRequest = new ListGroupsRequest();
- return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest));
+ return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable());
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs
index 411c987f3..2d594293e 100644
--- a/Jellyfin.Api/Controllers/SystemController.cs
+++ b/Jellyfin.Api/Controllers/SystemController.cs
@@ -216,8 +216,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo()
{
var result = _network.GetMacAddresses()
- .Select(i => new WakeOnLanInfo(i))
- .ToList();
+ .Select(i => new WakeOnLanInfo(i));
return Ok(result);
}
}
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index c18fa29af..cd21c5f6f 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -211,7 +211,7 @@ namespace Jellyfin.Api.Controllers
if (item is IHasTrailers hasTrailers)
{
var trailers = hasTrailers.LocalTrailers;
- return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item));
+ return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable());
}
return Ok(item.GetExtras()
diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj
index 889f7dc9a..b5444138f 100644
--- a/Jellyfin.Api/Jellyfin.Api.csproj
+++ b/Jellyfin.Api/Jellyfin.Api.csproj
@@ -12,12 +12,8 @@
<NoWarn>AD0001</NoWarn>
</PropertyGroup>
- <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
- <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
- </PropertyGroup>
-
<ItemGroup>
- <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="7.0.1" />
+ <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="7.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.4.0" />
diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs
index f43822da7..e293c461c 100644
--- a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs
+++ b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs
@@ -14,14 +14,12 @@ namespace Jellyfin.Api.Models.LiveTvDtos
/// <summary>
/// Gets or sets list of tuner channels.
/// </summary>
- [SuppressMessage("Microsoft.Performance", "CA2227:ReadOnlyRemoveSetter", MessageId = "TunerChannels", Justification = "Imported from ServiceStack")]
- public List<TunerChannelMapping> TunerChannels { get; set; } = null!;
+ required public IReadOnlyList<TunerChannelMapping> TunerChannels { get; set; }
/// <summary>
/// Gets or sets list of provider channels.
/// </summary>
- [SuppressMessage("Microsoft.Performance", "CA2227:ReadOnlyRemoveSetter", MessageId = "ProviderChannels", Justification = "Imported from ServiceStack")]
- public List<NameIdPair> ProviderChannels { get; set; } = null!;
+ required public IReadOnlyList<NameIdPair> ProviderChannels { get; set; }
/// <summary>
/// Gets or sets list of mappings.
diff --git a/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs b/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs
deleted file mode 100644
index 6136a2ff9..000000000
--- a/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System;
-using MediaBrowser.Model.Drawing;
-using SkiaSharp;
-
-namespace Jellyfin.Drawing.Skia
-{
- /// <summary>
- /// Static helper class used to draw percentage-played indicators on images.
- /// </summary>
- public static class PercentPlayedDrawer
- {
- private const int IndicatorHeight = 8;
-
- /// <summary>
- /// Draw a percentage played indicator on a canvas.
- /// </summary>
- /// <param name="canvas">The canvas to draw the indicator on.</param>
- /// <param name="imageSize">The size of the image being drawn on.</param>
- /// <param name="percent">The percentage played to display with the indicator.</param>
- public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent)
- {
- using var paint = new SKPaint();
- var endX = imageSize.Width - 1;
- var endY = imageSize.Height - 1;
-
- paint.Color = SKColor.Parse("#99000000");
- paint.Style = SKPaintStyle.Fill;
- canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint);
-
- double foregroundWidth = (endX * percent) / 100;
-
- paint.Color = SKColor.Parse("#FF00A4DC");
- canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint);
- }
- }
-}
diff --git a/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs b/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs
deleted file mode 100644
index 2a3729942..000000000
--- a/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using MediaBrowser.Model.Drawing;
-using SkiaSharp;
-
-namespace Jellyfin.Drawing.Skia
-{
- /// <summary>
- /// Static helper class for drawing 'played' indicators.
- /// </summary>
- public static class PlayedIndicatorDrawer
- {
- private const int OffsetFromTopRightCorner = 38;
-
- /// <summary>
- /// Draw a 'played' indicator in the top right corner of a canvas.
- /// </summary>
- /// <param name="canvas">The canvas to draw the indicator on.</param>
- /// <param name="imageSize">
- /// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the
- /// indicator.
- /// </param>
- public static void DrawPlayedIndicator(SKCanvas canvas, ImageDimensions imageSize)
- {
- var x = imageSize.Width - OffsetFromTopRightCorner;
-
- using var paint = new SKPaint
- {
- Color = SKColor.Parse("#CC00A4DC"),
- Style = SKPaintStyle.Fill
- };
-
- canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
-
- paint.Color = new SKColor(255, 255, 255, 255);
- paint.TextSize = 30;
- paint.IsAntialias = true;
-
- // or:
- // var emojiChar = 0x1F680;
- const string Text = "✔️";
- var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32);
-
- // ask the font manager for a font with that character
- paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar);
-
- canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint);
- }
- }
-}
diff --git a/Jellyfin.Drawing.Skia/SkiaCodecException.cs b/Jellyfin.Drawing.Skia/SkiaCodecException.cs
deleted file mode 100644
index 9a50a4d62..000000000
--- a/Jellyfin.Drawing.Skia/SkiaCodecException.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System.Globalization;
-using SkiaSharp;
-
-namespace Jellyfin.Drawing.Skia
-{
- /// <summary>
- /// Represents errors that occur during interaction with Skia codecs.
- /// </summary>
- public class SkiaCodecException : SkiaException
- {
- /// <summary>
- /// Initializes a new instance of the <see cref="SkiaCodecException" /> class.
- /// </summary>
- /// <param name="result">The non-successful codec result returned by Skia.</param>
- public SkiaCodecException(SKCodecResult result)
- {
- CodecResult = result;
- }
-
- /// <summary>
- /// Initializes a new instance of the <see cref="SkiaCodecException" /> class
- /// with a specified error message.
- /// </summary>
- /// <param name="result">The non-successful codec result returned by Skia.</param>
- /// <param name="message">The message that describes the error.</param>
- public SkiaCodecException(SKCodecResult result, string message)
- : base(message)
- {
- CodecResult = result;
- }
-
- /// <summary>
- /// Gets the non-successful codec result returned by Skia.
- /// </summary>
- public SKCodecResult CodecResult { get; }
-
- /// <inheritdoc />
- public override string ToString()
- => string.Format(
- CultureInfo.InvariantCulture,
- "Non-success codec result: {0}\n{1}",
- CodecResult,
- base.ToString());
- }
-}
diff --git a/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/Jellyfin.Drawing.Skia/SkiaEncoder.cs
deleted file mode 100644
index 9171c4d6e..000000000
--- a/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ /dev/null
@@ -1,545 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using BlurHashSharp.SkiaSharp;
-using Jellyfin.Extensions;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Drawing;
-using MediaBrowser.Model.Drawing;
-using Microsoft.Extensions.Logging;
-using SkiaSharp;
-using SKSvg = SkiaSharp.Extended.Svg.SKSvg;
-
-namespace Jellyfin.Drawing.Skia
-{
- /// <summary>
- /// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images.
- /// </summary>
- public class SkiaEncoder : IImageEncoder
- {
- private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
-
- private readonly ILogger<SkiaEncoder> _logger;
- private readonly IApplicationPaths _appPaths;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
- /// </summary>
- /// <param name="logger">The application logger.</param>
- /// <param name="appPaths">The application paths.</param>
- public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths)
- {
- _logger = logger;
- _appPaths = appPaths;
- }
-
- /// <inheritdoc/>
- public string Name => "Skia";
-
- /// <inheritdoc/>
- public bool SupportsImageCollageCreation => true;
-
- /// <inheritdoc/>
- public bool SupportsImageEncoding => true;
-
- /// <inheritdoc/>
- public IReadOnlyCollection<string> SupportedInputFormats =>
- new HashSet<string>(StringComparer.OrdinalIgnoreCase)
- {
- "jpeg",
- "jpg",
- "png",
- "dng",
- "webp",
- "gif",
- "bmp",
- "ico",
- "astc",
- "ktx",
- "pkm",
- "wbmp",
- // TODO: check if these are supported on multiple platforms
- // https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454
- // working on windows at least
- "cr2",
- "nef",
- "arw"
- };
-
- /// <inheritdoc/>
- public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
- => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png };
-
- /// <summary>
- /// Check if the native lib is available.
- /// </summary>
- /// <returns>True if the native lib is available, otherwise false.</returns>
- public static bool IsNativeLibAvailable()
- {
- try
- {
- // test an operation that requires the native library
- SKPMColor.PreMultiply(SKColors.Black);
- return true;
- }
- catch (Exception)
- {
- return false;
- }
- }
-
- /// <summary>
- /// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
- /// </summary>
- /// <param name="selectedFormat">The format to convert.</param>
- /// <returns>The converted format.</returns>
- public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat)
- {
- return selectedFormat switch
- {
- ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
- ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
- ImageFormat.Gif => SKEncodedImageFormat.Gif,
- ImageFormat.Webp => SKEncodedImageFormat.Webp,
- _ => SKEncodedImageFormat.Png
- };
- }
-
- /// <inheritdoc />
- /// <exception cref="FileNotFoundException">The path is not valid.</exception>
- public ImageDimensions GetImageSize(string path)
- {
- if (!File.Exists(path))
- {
- throw new FileNotFoundException("File not found", path);
- }
-
- var extension = Path.GetExtension(path.AsSpan());
- if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
- {
- var svg = new SKSvg();
- svg.Load(path);
- return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height));
- }
-
- using var codec = SKCodec.Create(path, out SKCodecResult result);
- switch (result)
- {
- case SKCodecResult.Success:
- var info = codec.Info;
- return new ImageDimensions(info.Width, info.Height);
- case SKCodecResult.Unimplemented:
- _logger.LogDebug("Image format not supported: {FilePath}", path);
- return new ImageDimensions(0, 0);
- default:
- _logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result);
- return new ImageDimensions(0, 0);
- }
- }
-
- /// <inheritdoc />
- /// <exception cref="ArgumentNullException">The path is null.</exception>
- /// <exception cref="FileNotFoundException">The path is not valid.</exception>
- /// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception>
- public string GetImageBlurHash(int xComp, int yComp, string path)
- {
- ArgumentException.ThrowIfNullOrEmpty(path);
-
- var extension = Path.GetExtension(path.AsSpan()).TrimStart('.');
- if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path);
- return string.Empty;
- }
-
- // Any larger than 128x128 is too slow and there's no visually discernible difference
- return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128);
- }
-
- private bool RequiresSpecialCharacterHack(string path)
- {
- for (int i = 0; i < path.Length; i++)
- {
- if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter)
- {
- return true;
- }
- }
-
- return path.HasDiacritics();
- }
-
- private string NormalizePath(string path)
- {
- if (!RequiresSpecialCharacterHack(path))
- {
- return path;
- }
-
- var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path));
- var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid.");
- Directory.CreateDirectory(directory);
- File.Copy(path, tempPath, true);
-
- return tempPath;
- }
-
- private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation)
- {
- if (!orientation.HasValue)
- {
- return SKEncodedOrigin.TopLeft;
- }
-
- return orientation.Value switch
- {
- ImageOrientation.TopRight => SKEncodedOrigin.TopRight,
- ImageOrientation.RightTop => SKEncodedOrigin.RightTop,
- ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom,
- ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop,
- ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom,
- ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight,
- ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft,
- _ => SKEncodedOrigin.TopLeft
- };
- }
-
- /// <summary>
- /// Decode an image.
- /// </summary>
- /// <param name="path">The filepath of the image to decode.</param>
- /// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param>
- /// <param name="orientation">The orientation of the image.</param>
- /// <param name="origin">The detected origin of the image.</param>
- /// <returns>The resulting bitmap of the image.</returns>
- internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin)
- {
- if (!File.Exists(path))
- {
- throw new FileNotFoundException("File not found", path);
- }
-
- var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path));
-
- if (requiresTransparencyHack || forceCleanBitmap)
- {
- using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res);
- if (res != SKCodecResult.Success)
- {
- origin = GetSKEncodedOrigin(orientation);
- return null;
- }
-
- // create the bitmap
- var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
-
- // decode
- _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
-
- origin = codec.EncodedOrigin;
-
- return bitmap;
- }
-
- var resultBitmap = SKBitmap.Decode(NormalizePath(path));
-
- if (resultBitmap is null)
- {
- return Decode(path, true, orientation, out origin);
- }
-
- // If we have to resize these they often end up distorted
- if (resultBitmap.ColorType == SKColorType.Gray8)
- {
- using (resultBitmap)
- {
- return Decode(path, true, orientation, out origin);
- }
- }
-
- origin = SKEncodedOrigin.TopLeft;
- return resultBitmap;
- }
-
- private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation)
- {
- if (autoOrient)
- {
- var bitmap = Decode(path, true, orientation, out var origin);
-
- if (bitmap is not null && origin != SKEncodedOrigin.TopLeft)
- {
- using (bitmap)
- {
- return OrientImage(bitmap, origin);
- }
- }
-
- return bitmap;
- }
-
- return Decode(path, false, orientation, out _);
- }
-
- private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
- {
- var needsFlip = origin == SKEncodedOrigin.LeftBottom
- || origin == SKEncodedOrigin.LeftTop
- || origin == SKEncodedOrigin.RightBottom
- || origin == SKEncodedOrigin.RightTop;
- var rotated = needsFlip
- ? new SKBitmap(bitmap.Height, bitmap.Width)
- : new SKBitmap(bitmap.Width, bitmap.Height);
- using var surface = new SKCanvas(rotated);
- var midX = (float)rotated.Width / 2;
- var midY = (float)rotated.Height / 2;
-
- switch (origin)
- {
- case SKEncodedOrigin.TopRight:
- surface.Scale(-1, 1, midX, midY);
- break;
- case SKEncodedOrigin.BottomRight:
- surface.RotateDegrees(180, midX, midY);
- break;
- case SKEncodedOrigin.BottomLeft:
- surface.Scale(1, -1, midX, midY);
- break;
- case SKEncodedOrigin.LeftTop:
- surface.Translate(0, -rotated.Height);
- surface.Scale(1, -1, midX, midY);
- surface.RotateDegrees(-90);
- break;
- case SKEncodedOrigin.RightTop:
- surface.Translate(rotated.Width, 0);
- surface.RotateDegrees(90);
- break;
- case SKEncodedOrigin.RightBottom:
- surface.Translate(rotated.Width, 0);
- surface.Scale(1, -1, midX, midY);
- surface.RotateDegrees(90);
- break;
- case SKEncodedOrigin.LeftBottom:
- surface.Translate(0, rotated.Height);
- surface.RotateDegrees(-90);
- break;
- }
-
- surface.DrawBitmap(bitmap, 0, 0);
- return rotated;
- }
-
- /// <summary>
- /// Resizes an image on the CPU, by utilizing a surface and canvas.
- ///
- /// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect.
- /// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP](https://docs.gimp.org/2.10/en/gimp-filter-convolution-matrix.html).
- /// </summary>
- /// <param name="source">The source bitmap.</param>
- /// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param>
- /// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param>
- /// <param name="isDither">This enables dithering on the SKPaint instance.</param>
- /// <returns>The resized image.</returns>
- internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false)
- {
- using var surface = SKSurface.Create(targetInfo);
- using var canvas = surface.Canvas;
- using var paint = new SKPaint
- {
- FilterQuality = SKFilterQuality.High,
- IsAntialias = isAntialias,
- IsDither = isDither
- };
-
- var kernel = new float[9]
- {
- 0, -.1f, 0,
- -.1f, 1.4f, -.1f,
- 0, -.1f, 0,
- };
-
- var kernelSize = new SKSizeI(3, 3);
- var kernelOffset = new SKPointI(1, 1);
-
- paint.ImageFilter = SKImageFilter.CreateMatrixConvolution(
- kernelSize,
- kernel,
- 1f,
- 0f,
- kernelOffset,
- SKShaderTileMode.Clamp,
- true);
-
- canvas.DrawBitmap(
- source,
- SKRect.Create(0, 0, source.Width, source.Height),
- SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
- paint);
-
- return surface.Snapshot();
- }
-
- /// <inheritdoc/>
- public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
- {
- ArgumentException.ThrowIfNullOrEmpty(inputPath);
- ArgumentException.ThrowIfNullOrEmpty(outputPath);
-
- var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.');
- if (!SupportedInputFormats.Contains(inputFormat, StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogDebug("Unable to encode image due to unsupported format: {ImagePath}", inputPath);
- return inputPath;
- }
-
- var skiaOutputFormat = GetImageFormat(outputFormat);
-
- var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
- var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer);
- var blur = options.Blur ?? 0;
- var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
-
- using var bitmap = GetBitmap(inputPath, autoOrient, orientation);
- if (bitmap is null)
- {
- throw new InvalidDataException($"Skia unable to read image {inputPath}");
- }
-
- var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
-
- if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
- {
- // Just spit out the original file if all the options are default
- return inputPath;
- }
-
- var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
-
- var width = newImageSize.Width;
- var height = newImageSize.Height;
-
- // scale image (the FromImage creates a copy)
- var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace);
- using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo));
-
- // If all we're doing is resizing then we can stop now
- if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator)
- {
- var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
- Directory.CreateDirectory(outputDirectory);
- using var outputStream = new SKFileWStream(outputPath);
- using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels());
- resizedBitmap.Encode(outputStream, skiaOutputFormat, quality);
- return outputPath;
- }
-
- // create bitmap to use for canvas drawing used to draw into bitmap
- using var saveBitmap = new SKBitmap(width, height);
- using var canvas = new SKCanvas(saveBitmap);
- // set background color if present
- if (hasBackgroundColor)
- {
- canvas.Clear(SKColor.Parse(options.BackgroundColor));
- }
-
- // Add blur if option is present
- if (blur > 0)
- {
- // create image from resized bitmap to apply blur
- using var paint = new SKPaint();
- using var filter = SKImageFilter.CreateBlur(blur, blur);
- paint.ImageFilter = filter;
- canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint);
- }
- else
- {
- // draw resized bitmap onto canvas
- canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height));
- }
-
- // If foreground layer present then draw
- if (hasForegroundColor)
- {
- if (!double.TryParse(options.ForegroundLayer, out double opacity))
- {
- opacity = .4;
- }
-
- canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
- }
-
- if (hasIndicator)
- {
- DrawIndicator(canvas, width, height, options);
- }
-
- var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
- Directory.CreateDirectory(directory);
- using (var outputStream = new SKFileWStream(outputPath))
- {
- using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels()))
- {
- pixmap.Encode(outputStream, skiaOutputFormat, quality);
- }
- }
-
- return outputPath;
- }
-
- /// <inheritdoc/>
- public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
- {
- double ratio = (double)options.Width / options.Height;
-
- if (ratio >= 1.4)
- {
- new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height, libraryName);
- }
- else if (ratio >= .9)
- {
- new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
- }
- else
- {
- // TODO: Create Poster collage capability
- new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
- }
- }
-
- /// <inheritdoc />
- public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
- {
- var splashBuilder = new SplashscreenBuilder(this);
- var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
- splashBuilder.GenerateSplash(posters, backdrops, outputPath);
- }
-
- private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
- {
- try
- {
- var currentImageSize = new ImageDimensions(imageWidth, imageHeight);
-
- if (options.AddPlayedIndicator)
- {
- PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize);
- }
- else if (options.UnplayedCount.HasValue)
- {
- UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value);
- }
-
- if (options.PercentPlayed > 0)
- {
- PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed);
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error drawing indicator overlay");
- }
- }
- }
-}
diff --git a/Jellyfin.Drawing.Skia/SkiaException.cs b/Jellyfin.Drawing.Skia/SkiaException.cs
deleted file mode 100644
index 5b272eac5..000000000
--- a/Jellyfin.Drawing.Skia/SkiaException.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System;
-
-namespace Jellyfin.Drawing.Skia
-{
- /// <summary>
- /// Represents errors that occur during interaction with Skia.
- /// </summary>
- public class SkiaException : Exception
- {
- /// <summary>
- /// Initializes a new instance of the <see cref="SkiaException"/> class.
- /// </summary>
- public SkiaException()
- {
- }
-
- /// <summary>
- /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message.
- /// </summary>
- /// <param name="message">The message that describes the error.</param>
- public SkiaException(string message) : base(message)
- {
- }
-
- /// <summary>
- /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message and a
- /// reference to the inner exception that is the cause of this exception.
- /// </summary>
- /// <param name="message">The error message that explains the reason for the exception.</param>
- /// <param name="innerException">
- /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if
- /// no inner exception is specified.
- /// </param>
- public SkiaException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
- }
-}
diff --git a/Jellyfin.Drawing.Skia/SkiaHelper.cs b/Jellyfin.Drawing.Skia/SkiaHelper.cs
deleted file mode 100644
index 23e92dcb2..000000000
--- a/Jellyfin.Drawing.Skia/SkiaHelper.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using System.Collections.Generic;
-using SkiaSharp;
-
-namespace Jellyfin.Drawing.Skia
-{
- /// <summary>
- /// Class containing helper methods for working with SkiaSharp.
- /// </summary>
- public static class SkiaHelper
- {
- /// <summary>
- /// Gets the next valid image as a bitmap.
- /// </summary>
- /// <param name="skiaEncoder">The current skia encoder.</param>
- /// <param name="paths">The list of image paths.</param>
- /// <param name="currentIndex">The current checked index.</param>
- /// <param name="newIndex">The new index.</param>
- /// <returns>A valid bitmap, or null if no bitmap exists after <c>currentIndex</c>.</returns>
- public static SKBitmap? GetNextValidImage(SkiaEncoder skiaEncoder, IReadOnlyList<string> paths, int currentIndex, out int newIndex)
- {
- var imagesTested = new Dictionary<int, int>();
- SKBitmap? bitmap = null;
-
- while (imagesTested.Count < paths.Count)
- {
- if (currentIndex >= paths.Count)
- {
- currentIndex = 0;
- }
-
- bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _);
-
- imagesTested[currentIndex] = 0;
-
- currentIndex++;
-
- if (bitmap is not null)
- {
- break;
- }
- }
-
- newIndex = currentIndex;
- return bitmap;
- }
- }
-}
diff --git a/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs b/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs
deleted file mode 100644
index 7fbae3349..000000000
--- a/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using System;
-using System.Collections.Generic;
-using SkiaSharp;
-
-namespace Jellyfin.Drawing.Skia
-{
- /// <summary>
- /// Used to build the splashscreen.
- /// </summary>
- public class SplashscreenBuilder
- {
- private const int FinalWidth = 1920;
- private const int FinalHeight = 1080;
- // generated collage resolution should be higher than the final resolution
- private const int WallWidth = FinalWidth * 3;
- private const int WallHeight = FinalHeight * 2;
- private const int Rows = 6;
- private const int Spacing = 20;
-
- private readonly SkiaEncoder _skiaEncoder;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class.
- /// </summary>
- /// <param name="skiaEncoder">The SkiaEncoder.</param>
- public SplashscreenBuilder(SkiaEncoder skiaEncoder)
- {
- _skiaEncoder = skiaEncoder;
- }
-
- /// <summary>
- /// Generate a splashscreen.
- /// </summary>
- /// <param name="posters">The poster paths.</param>
- /// <param name="backdrops">The landscape paths.</param>
- /// <param name="outputPath">The output path.</param>
- public void GenerateSplash(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops, string outputPath)
- {
- using var wall = GenerateCollage(posters, backdrops);
- using var transformed = Transform3D(wall);
-
- using var outputStream = new SKFileWStream(outputPath);
- using var pixmap = new SKPixmap(new SKImageInfo(FinalWidth, FinalHeight), transformed.GetPixels());
- pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(outputPath), 90);
- }
-
- /// <summary>
- /// Generates a collage of posters and landscape pictures.
- /// </summary>
- /// <param name="posters">The poster paths.</param>
- /// <param name="backdrops">The landscape paths.</param>
- /// <returns>The created collage as a bitmap.</returns>
- private SKBitmap GenerateCollage(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
- {
- var posterIndex = 0;
- var backdropIndex = 0;
-
- var bitmap = new SKBitmap(WallWidth, WallHeight);
- using var canvas = new SKCanvas(bitmap);
- canvas.Clear(SKColors.Black);
-
- int posterHeight = WallHeight / 6;
-
- for (int i = 0; i < Rows; i++)
- {
- int imageCounter = Random.Shared.Next(0, 5);
- int currentWidthPos = i * 75;
- int currentHeight = i * (posterHeight + Spacing);
-
- while (currentWidthPos < WallWidth)
- {
- SKBitmap? currentImage;
-
- switch (imageCounter)
- {
- case 0:
- case 2:
- case 3:
- currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex);
- posterIndex = newPosterIndex;
- break;
- default:
- currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex);
- backdropIndex = newBackdropIndex;
- break;
- }
-
- if (currentImage is null)
- {
- throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!");
- }
-
- // resize to the same aspect as the original
- var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height);
- using var resizedBitmap = new SKBitmap(imageWidth, posterHeight);
- currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High);
-
- // draw on canvas
- canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight);
-
- currentWidthPos += imageWidth + Spacing;
-
- currentImage.Dispose();
-
- if (imageCounter >= 4)
- {
- imageCounter = 0;
- }
- else
- {
- imageCounter++;
- }
- }
- }
-
- return bitmap;
- }
-
- /// <summary>
- /// Transform the collage in 3D space.
- /// </summary>
- /// <param name="input">The bitmap to transform.</param>
- /// <returns>The transformed image.</returns>
- private SKBitmap Transform3D(SKBitmap input)
- {
- var bitmap = new SKBitmap(FinalWidth, FinalHeight);
- using var canvas = new SKCanvas(bitmap);
- canvas.Clear(SKColors.Black);
- var matrix = new SKMatrix
- {
- ScaleX = 0.324108899f,
- ScaleY = 0.563934922f,
- SkewX = -0.244337708f,
- SkewY = 0.0377609022f,
- TransX = 42.0407715f,
- TransY = -198.104706f,
- Persp0 = -9.08959337E-05f,
- Persp1 = 6.85242048E-05f,
- Persp2 = 0.988209724f
- };
-
- canvas.SetMatrix(matrix);
- canvas.DrawBitmap(input, 0, 0);
-
- return bitmap;
- }
- }
-}
diff --git a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
deleted file mode 100644
index c8b8f3ace..000000000
--- a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
+++ /dev/null
@@ -1,186 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Text.RegularExpressions;
-using SkiaSharp;
-
-namespace Jellyfin.Drawing.Skia
-{
- /// <summary>
- /// Used to build collages of multiple images arranged in vertical strips.
- /// </summary>
- public class StripCollageBuilder
- {
- private readonly SkiaEncoder _skiaEncoder;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="StripCollageBuilder"/> class.
- /// </summary>
- /// <param name="skiaEncoder">The encoder to use for building collages.</param>
- public StripCollageBuilder(SkiaEncoder skiaEncoder)
- {
- _skiaEncoder = skiaEncoder;
- }
-
- /// <summary>
- /// Check which format an image has been encoded with using its filename extension.
- /// </summary>
- /// <param name="outputPath">The path to the image to get the format for.</param>
- /// <returns>The image format.</returns>
- public static SKEncodedImageFormat GetEncodedFormat(string outputPath)
- {
- ArgumentNullException.ThrowIfNull(outputPath);
-
- var ext = Path.GetExtension(outputPath);
-
- if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase)
- || string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase))
- {
- return SKEncodedImageFormat.Jpeg;
- }
-
- if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase))
- {
- return SKEncodedImageFormat.Webp;
- }
-
- if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase))
- {
- return SKEncodedImageFormat.Gif;
- }
-
- if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase))
- {
- return SKEncodedImageFormat.Bmp;
- }
-
- // default to png
- return SKEncodedImageFormat.Png;
- }
-
- /// <summary>
- /// Create a square collage.
- /// </summary>
- /// <param name="paths">The paths of the images to use in the collage.</param>
- /// <param name="outputPath">The path at which to place the resulting collage image.</param>
- /// <param name="width">The desired width of the collage.</param>
- /// <param name="height">The desired height of the collage.</param>
- public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height)
- {
- using var bitmap = BuildSquareCollageBitmap(paths, width, height);
- using var outputStream = new SKFileWStream(outputPath);
- using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
- pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
- }
-
- /// <summary>
- /// Create a thumb collage.
- /// </summary>
- /// <param name="paths">The paths of the images to use in the collage.</param>
- /// <param name="outputPath">The path at which to place the resulting image.</param>
- /// <param name="width">The desired width of the collage.</param>
- /// <param name="height">The desired height of the collage.</param>
- /// <param name="libraryName">The name of the library to draw on the collage.</param>
- public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? libraryName)
- {
- using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName);
- using var outputStream = new SKFileWStream(outputPath);
- using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
- pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
- }
-
- private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName)
- {
- var bitmap = new SKBitmap(width, height);
-
- using var canvas = new SKCanvas(bitmap);
- canvas.Clear(SKColors.Black);
-
- using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
- if (backdrop is null)
- {
- return bitmap;
- }
-
- // resize to the same aspect as the original
- var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width);
- using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace));
- // draw the backdrop
- canvas.DrawImage(residedBackdrop, 0, 0);
-
- // draw shadow rectangle
- using var paintColor = new SKPaint
- {
- Color = SKColors.Black.WithAlpha(0x78),
- Style = SKPaintStyle.Fill
- };
- canvas.DrawRect(0, 0, width, height, paintColor);
-
- var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright);
-
- // use the system fallback to find a typeface for the given CJK character
- var nonCjkPattern = @"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]";
- var filteredName = Regex.Replace(libraryName ?? string.Empty, nonCjkPattern, string.Empty);
- if (!string.IsNullOrEmpty(filteredName))
- {
- typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]);
- }
-
- // draw library name
- using var textPaint = new SKPaint
- {
- Color = SKColors.White,
- Style = SKPaintStyle.Fill,
- TextSize = 112,
- TextAlign = SKTextAlign.Center,
- Typeface = typeFace,
- IsAntialias = true
- };
-
- // scale down text to 90% of the width if text is larger than 95% of the width
- var textWidth = textPaint.MeasureText(libraryName);
- if (textWidth > width * 0.95)
- {
- textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth;
- }
-
- canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint);
-
- return bitmap;
- }
-
- private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height)
- {
- var bitmap = new SKBitmap(width, height);
- var imageIndex = 0;
- var cellWidth = width / 2;
- var cellHeight = height / 2;
-
- using var canvas = new SKCanvas(bitmap);
- for (var x = 0; x < 2; x++)
- {
- for (var y = 0; y < 2; y++)
- {
- using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex);
- imageIndex = newIndex;
-
- if (currentBitmap is null)
- {
- continue;
- }
-
- // Scale image. The FromBitmap creates a copy
- var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace);
- using var resizedBitmap = SKBitmap.FromImage(SkiaEncoder.ResizeImage(currentBitmap, imageInfo));
-
- // draw this image into the strip at the next position
- var xPos = x * cellWidth;
- var yPos = y * cellHeight;
- canvas.DrawBitmap(resizedBitmap, xPos, yPos);
- }
- }
-
- return bitmap;
- }
- }
-}
diff --git a/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs b/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs
deleted file mode 100644
index 58f887c96..000000000
--- a/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using System.Globalization;
-using MediaBrowser.Model.Drawing;
-using SkiaSharp;
-
-namespace Jellyfin.Drawing.Skia
-{
- /// <summary>
- /// Static helper class for drawing unplayed count indicators.
- /// </summary>
- public static class UnplayedCountIndicator
- {
- /// <summary>
- /// The x-offset used when drawing an unplayed count indicator.
- /// </summary>
- private const int OffsetFromTopRightCorner = 38;
-
- /// <summary>
- /// Draw an unplayed count indicator in the top right corner of a canvas.
- /// </summary>
- /// <param name="canvas">The canvas to draw the indicator on.</param>
- /// <param name="imageSize">
- /// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the
- /// indicator.
- /// </param>
- /// <param name="count">The number to draw in the indicator.</param>
- public static void DrawUnplayedCountIndicator(SKCanvas canvas, ImageDimensions imageSize, int count)
- {
- var x = imageSize.Width - OffsetFromTopRightCorner;
- var text = count.ToString(CultureInfo.InvariantCulture);
-
- using var paint = new SKPaint
- {
- Color = SKColor.Parse("#CC00A4DC"),
- Style = SKPaintStyle.Fill
- };
-
- canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
-
- paint.Color = new SKColor(255, 255, 255, 255);
- paint.TextSize = 24;
- paint.IsAntialias = true;
-
- var y = OffsetFromTopRightCorner + 9;
-
- if (text.Length == 1)
- {
- x -= 7;
- }
-
- if (text.Length == 2)
- {
- x -= 13;
- }
- else if (text.Length >= 3)
- {
- x -= 15;
- y -= 2;
- paint.TextSize = 18;
- }
-
- canvas.DrawText(text, x, y, paint);
- }
- }
-}
diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
index e98290673..bc437c5d7 100644
--- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
+++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
@@ -6,10 +6,6 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
- <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
- <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
- </PropertyGroup>
-
<!-- Code analysers-->
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
@@ -26,15 +22,15 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="EFCoreSecondLevelCacheInterceptor" Version="3.8.1" />
+ <PackageReference Include="EFCoreSecondLevelCacheInterceptor" Version="3.8.2" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.1" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.1" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.1">
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.2" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.2" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
- <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.1">
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
index 4fda8f5a4..960195467 100644
--- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
@@ -54,7 +54,8 @@ namespace Jellyfin.Server.Implementations.Users
foreach (var resetFile in Directory.EnumerateFiles(_passwordResetFileBaseDir, $"{BaseResetFileName}*"))
{
SerializablePasswordReset spr;
- await using (var str = AsyncFile.OpenRead(resetFile))
+ var str = AsyncFile.OpenRead(resetFile);
+ await using (str.ConfigureAwait(false))
{
spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false)
?? throw new ResourceNotFoundException($"Provided path ({resetFile}) is not valid.");
@@ -107,7 +108,8 @@ namespace Jellyfin.Server.Implementations.Users
UserName = user.Username
};
- await using (FileStream fileStream = AsyncFile.OpenWrite(filePath))
+ FileStream fileStream = AsyncFile.OpenWrite(filePath);
+ await using (fileStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
}
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index ae3fcad29..19ac007b9 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -157,7 +157,9 @@ namespace Jellyfin.Server.Implementations.Users
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
- OnUserUpdated?.Invoke(this, new GenericEventArgs<User>(user));
+ var eventArgs = new UserUpdatedEventArgs(user);
+ await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
+ OnUserUpdated?.Invoke(this, eventArgs);
}
/// <inheritdoc/>
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs
index 002193baf..d70b8f3ab 100644
--- a/Jellyfin.Server/CoreAppHost.cs
+++ b/Jellyfin.Server/CoreAppHost.cs
@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.Reflection;
-using Emby.Drawing;
using Emby.Server.Implementations;
using Emby.Server.Implementations.Session;
using Jellyfin.Api.WebSocketListeners;
+using Jellyfin.Drawing;
using Jellyfin.Drawing.Skia;
using Jellyfin.Server.Implementations;
using Jellyfin.Server.Implementations.Activity;
diff --git a/Jellyfin.Server/Filters/FileRequestFilter.cs b/Jellyfin.Server/Filters/FileRequestFilter.cs
index 69e10994f..bb5d6a412 100644
--- a/Jellyfin.Server/Filters/FileRequestFilter.cs
+++ b/Jellyfin.Server/Filters/FileRequestFilter.cs
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Filters
{
if (attribute is AcceptsFileAttribute acceptsFileAttribute)
{
- operation.RequestBody = GetRequestBody(acceptsFileAttribute.GetContentTypes());
+ operation.RequestBody = GetRequestBody(acceptsFileAttribute.ContentTypes);
break;
}
}
diff --git a/Jellyfin.Server/Filters/FileResponseFilter.cs b/Jellyfin.Server/Filters/FileResponseFilter.cs
index 544fdbfd6..1a4559d26 100644
--- a/Jellyfin.Server/Filters/FileResponseFilter.cs
+++ b/Jellyfin.Server/Filters/FileResponseFilter.cs
@@ -40,7 +40,7 @@ namespace Jellyfin.Server.Filters
response.Value.Content.Clear();
// Add all content-types as file.
- foreach (var contentType in producesFileAttribute.GetContentTypes())
+ foreach (var contentType in producesFileAttribute.ContentTypes)
{
response.Value.Content.Add(contentType, _openApiMediaType);
}
diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index ac2086935..195d7f3a9 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -37,8 +37,8 @@
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
- <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.1" />
- <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.1" />
+ <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.2" />
+ <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.2" />
<PackageReference Include="prometheus-net" Version="7.0.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="7.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
@@ -48,13 +48,13 @@
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Graylog" Version="2.3.0" />
- <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.3" />
+ <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.4" />
</ItemGroup>
<ItemGroup>
- <ProjectReference Include="..\Emby.Drawing\Emby.Drawing.csproj" />
+ <ProjectReference Include="..\src\Jellyfin.Drawing\Jellyfin.Drawing.csproj" />
<ProjectReference Include="..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" />
- <ProjectReference Include="..\Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj" />
+ <ProjectReference Include="..\src\Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj" />
<ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" />
<ProjectReference Include="..\src\Jellyfin.MediaEncoding.Hls\Jellyfin.MediaEncoding.Hls.csproj" />
</ItemGroup>
diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
index f6d8c9cc0..9e12c2e6b 100644
--- a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
@@ -38,7 +38,7 @@ namespace Jellyfin.Server.Migrations.Routines
/// <inheritdoc/>
public void Perform()
{
- _serverConfigurationManager.Configuration.PluginRepositories.Add(_defaultRepositoryInfo);
+ _serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo };
_serverConfigurationManager.SaveConfiguration();
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
index 394f14d63..9cfaec46f 100644
--- a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
@@ -39,9 +39,9 @@ namespace Jellyfin.Server.Migrations.Routines
public void Perform()
{
// Only add if repository list is empty
- if (_serverConfigurationManager.Configuration.PluginRepositories.Count == 0)
+ if (_serverConfigurationManager.Configuration.PluginRepositories.Length == 0)
{
- _serverConfigurationManager.Configuration.PluginRepositories.Add(_defaultRepositoryInfo);
+ _serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo };
_serverConfigurationManager.SaveConfiguration();
}
}
diff --git a/Jellyfin.sln b/Jellyfin.sln
index 0514b9614..c0d2ec068 100644
--- a/Jellyfin.sln
+++ b/Jellyfin.sln
@@ -17,7 +17,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.XbmcMetadata",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.LocalMetadata", "MediaBrowser.LocalMetadata\MediaBrowser.LocalMetadata.csproj", "{7EF9F3E0-697D-42F3-A08F-19DEB5F84392}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.Drawing", "Emby.Drawing\Emby.Drawing.csproj", "{08FFF49B-F175-4807-A2B5-73B0EBD9F716}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing", "src\Jellyfin.Drawing\Jellyfin.Drawing.csproj", "{08FFF49B-F175-4807-A2B5-73B0EBD9F716}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.Photos", "Emby.Photos\Emby.Photos.csproj", "{89AB4548-770D-41FD-A891-8DAFF44F452C}"
EndProject
@@ -42,7 +42,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
SharedVersion.cs = SharedVersion.cs
EndProjectSection
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia", "Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj", "{154872D9-6C12-4007-96E3-8F70A58386CE}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia", "src\Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj", "{154872D9-6C12-4007-96E3-8F70A58386CE}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Api", "Jellyfin.Api\Jellyfin.Api.csproj", "{DFBEFB4C-DA19-4143-98B7-27320C7F7163}"
EndProject
@@ -287,6 +287,8 @@ Global
{DA9FD356-4894-4830-B208-D6BCE3E65B11} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{FE47334C-EFDE-4519-BD50-F24430FF360B} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{24960660-DE6C-47BF-AEEF-CEE8F19FE6C2} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {08FFF49B-F175-4807-A2B5-73B0EBD9F716} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
+ {154872D9-6C12-4007-96E3-8F70A58386CE} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index e586205c3..bccb4107f 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -1300,8 +1300,15 @@ namespace MediaBrowser.Controller.Entities
/// <summary>
/// Adds the children to list.
/// </summary>
- private void AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query)
+ private void AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders = null)
{
+ // Prevent infinite recursion of nested folders
+ visitedFolders ??= new HashSet<Folder>();
+ if (!visitedFolders.Add(this))
+ {
+ return;
+ }
+
// If Query.AlbumFolders is set, then enforce the format as per the db in that it permits sub-folders in music albums.
IEnumerable<BaseItem> children = null;
if ((query?.DisplayAlbumFolders ?? false) && (this is MusicAlbum))
@@ -1316,42 +1323,33 @@ namespace MediaBrowser.Controller.Entities
children = GetEligibleChildrenForRecursiveChildren(user);
}
- foreach (var child in children)
+ AddChildrenFromCollection(children, user, includeLinkedChildren, result, recursive, query, visitedFolders);
+
+ if (includeLinkedChildren)
{
- bool? isVisibleToUser = null;
+ AddChildrenFromCollection(GetLinkedChildren(user), user, includeLinkedChildren, result, recursive, query, visitedFolders);
+ }
+ }
- if (query is null || UserViewBuilder.FilterItem(child, query))
+ private void AddChildrenFromCollection(IEnumerable<BaseItem> children, User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders)
+ {
+ foreach (var child in children)
+ {
+ if (!child.IsVisible(user))
{
- isVisibleToUser = child.IsVisible(user);
-
- if (isVisibleToUser.Value)
- {
- result[child.Id] = child;
- }
+ continue;
}
- if (isVisibleToUser ?? child.IsVisible(user))
+ if (query is null || UserViewBuilder.FilterItem(child, query))
{
- if (recursive && child.IsFolder)
- {
- var folder = (Folder)child;
-
- folder.AddChildren(user, includeLinkedChildren, result, true, query);
- }
+ result[child.Id] = child;
}
- }
- if (includeLinkedChildren)
- {
- foreach (var child in GetLinkedChildren(user))
+ if (recursive && child.IsFolder)
{
- if (query is null || UserViewBuilder.FilterItem(child, query))
- {
- if (child.IsVisible(user))
- {
- result[child.Id] = child;
- }
- }
+ var folder = (Folder)child;
+
+ folder.AddChildren(user, includeLinkedChildren, result, true, query, visitedFolders);
}
}
}
diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs
index 3f30ac565..c83149a6d 100644
--- a/MediaBrowser.Controller/Entities/TV/Episode.cs
+++ b/MediaBrowser.Controller/Entities/TV/Episode.cs
@@ -320,7 +320,7 @@ namespace MediaBrowser.Controller.Entities.TV
if (!IsLocked)
{
- if (SourceType == SourceType.Library)
+ if (SourceType == SourceType.Library || SourceType == SourceType.LiveTV)
{
try
{
diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
index 4a66edb16..6434621c4 100644
--- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj
+++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
@@ -19,7 +19,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
- <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.1" />
+ <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.2" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="7.0.0" />
</ItemGroup>
diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs
index b16399598..eefc5d222 100644
--- a/MediaBrowser.Controller/Session/ISessionManager.cs
+++ b/MediaBrowser.Controller/Session/ISessionManager.cs
@@ -58,16 +58,6 @@ namespace MediaBrowser.Controller.Session
event EventHandler<SessionEventArgs> CapabilitiesChanged;
/// <summary>
- /// Occurs when [authentication failed].
- /// </summary>
- event EventHandler<GenericEventArgs<AuthenticationRequest>> AuthenticationFailed;
-
- /// <summary>
- /// Occurs when [authentication succeeded].
- /// </summary>
- event EventHandler<GenericEventArgs<AuthenticationResult>> AuthenticationSucceeded;
-
- /// <summary>
/// Gets the sessions.
/// </summary>
/// <value>The sessions.</value>
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 91bf42b15..d95f894c5 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -498,11 +498,12 @@ namespace MediaBrowser.MediaEncoding.Encoder
_logger.LogInformation("Starting {ProcessFileName} with args {ProcessArgs}", _ffprobePath, args);
+ var memoryStream = new MemoryStream();
+ await using (memoryStream.ConfigureAwait(false))
using (var processWrapper = new ProcessWrapper(process, this))
{
- await using var memoryStream = new MemoryStream();
StartProcess(processWrapper);
- await process.StandardOutput.BaseStream.CopyToAsync(memoryStream, cancellationToken);
+ await process.StandardOutput.BaseStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
memoryStream.Seek(0, SeekOrigin.Begin);
InternalMediaInfoResult result;
try
diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
index 7404c2868..e33cfc7a1 100644
--- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
+++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
@@ -11,10 +11,6 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
- <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
- <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
- </PropertyGroup>
-
<ItemGroup>
<Compile Include="..\SharedVersion.cs" />
</ItemGroup>
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index b7c2fd7b1..90bc49132 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -226,7 +226,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
await ExtractTextSubtitle(mediaSource, subtitleStream, outputCodec, outputPath, cancellationToken)
.ConfigureAwait(false);
- return new SubtitleInfo(outputPath, MediaProtocol.File, outputFormat, false);
+ return new SubtitleInfo()
+ {
+ Path = outputPath,
+ Protocol = MediaProtocol.File,
+ Format = outputFormat,
+ IsExternal = false
+ };
}
var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec)
@@ -240,11 +246,23 @@ namespace MediaBrowser.MediaEncoding.Subtitles
await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
- return new SubtitleInfo(outputPath, MediaProtocol.File, "srt", true);
+ return new SubtitleInfo()
+ {
+ Path = outputPath,
+ Protocol = MediaProtocol.File,
+ Format = "srt",
+ IsExternal = true
+ };
}
// It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with local subs)
- return new SubtitleInfo(subtitleStream.Path, _mediaSourceManager.GetPathProtocol(subtitleStream.Path), currentFormat, true);
+ return new SubtitleInfo()
+ {
+ Path = subtitleStream.Path,
+ Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
+ Format = currentFormat,
+ IsExternal = true
+ };
}
private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)
@@ -728,23 +746,17 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
- public readonly struct SubtitleInfo
+#pragma warning disable CA1034 // Nested types should not be visible
+ // Only public for the unit tests
+ public readonly record struct SubtitleInfo
{
- public SubtitleInfo(string path, MediaProtocol protocol, string format, bool isExternal)
- {
- Path = path;
- Protocol = protocol;
- Format = format;
- IsExternal = isExternal;
- }
-
- public string Path { get; }
+ public string Path { get; init; }
- public MediaProtocol Protocol { get; }
+ public MediaProtocol Protocol { get; init; }
- public string Format { get; }
+ public string Format { get; init; }
- public bool IsExternal { get; }
+ public bool IsExternal { get; init; }
}
}
}
diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
index a07ab7121..d3e042aba 100644
--- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs
+++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
@@ -194,7 +194,7 @@ namespace MediaBrowser.Model.Configuration
public string[] CodecsUsed { get; set; } = Array.Empty<string>();
- public List<RepositoryInfo> PluginRepositories { get; set; } = new List<RepositoryInfo>();
+ public RepositoryInfo[] PluginRepositories { get; set; } = Array.Empty<RepositoryInfo>();
public bool EnableExternalContentInSuggestions { get; set; } = true;
diff --git a/MediaBrowser.Model/LiveTv/LiveTvOptions.cs b/MediaBrowser.Model/LiveTv/LiveTvOptions.cs
index 4cece941c..25e5c7796 100644
--- a/MediaBrowser.Model/LiveTv/LiveTvOptions.cs
+++ b/MediaBrowser.Model/LiveTv/LiveTvOptions.cs
@@ -40,5 +40,9 @@ namespace MediaBrowser.Model.LiveTv
public string RecordingPostProcessor { get; set; }
public string RecordingPostProcessorArguments { get; set; }
+
+ public bool SaveRecordingNFO { get; set; } = true;
+
+ public bool SaveRecordingImages { get; set; } = true;
}
}
diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs
index 50e704060..e7c2cd255 100644
--- a/MediaBrowser.Providers/Manager/ImageSaver.cs
+++ b/MediaBrowser.Providers/Manager/ImageSaver.cs
@@ -264,7 +264,8 @@ namespace MediaBrowser.Providers.Manager
var fileStreamOptions = AsyncFile.WriteOptions;
fileStreamOptions.Mode = FileMode.Create;
fileStreamOptions.PreallocationSize = source.Length;
- await using (var fs = new FileStream(path, fileStreamOptions))
+ var fs = new FileStream(path, fileStreamOptions);
+ await using (fs.ConfigureAwait(false))
{
await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
}
diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
index a0f48840e..d621555f1 100644
--- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs
+++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
@@ -502,15 +502,17 @@ namespace MediaBrowser.Providers.Manager
break;
}
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-
- await _providerManager.SaveImage(
- item,
- stream,
- response.Content.Headers.ContentType?.MediaType,
- type,
- null,
- cancellationToken).ConfigureAwait(false);
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ await _providerManager.SaveImage(
+ item,
+ stream,
+ response.Content.Headers.ContentType?.MediaType,
+ type,
+ null,
+ cancellationToken).ConfigureAwait(false);
+ }
result.UpdateType |= ItemUpdateType.ImageUpdate;
return true;
@@ -626,14 +628,18 @@ namespace MediaBrowser.Providers.Manager
}
}
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
- await _providerManager.SaveImage(
- item,
- stream,
- response.Content.Headers.ContentType?.MediaType,
- imageType,
- null,
- cancellationToken).ConfigureAwait(false);
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ await _providerManager.SaveImage(
+ item,
+ stream,
+ response.Content.Headers.ContentType?.MediaType,
+ imageType,
+ null,
+ cancellationToken).ConfigureAwait(false);
+ }
+
result.UpdateType |= ItemUpdateType.ImageUpdate;
}
catch (HttpRequestException)
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index 914da33a9..0ce696edc 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -182,14 +182,17 @@ namespace MediaBrowser.Providers.Manager
contentType = MimeTypes.GetMimeType(url);
}
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
- await SaveImage(
- item,
- stream,
- contentType,
- type,
- imageIndex,
- cancellationToken).ConfigureAwait(false);
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ await SaveImage(
+ item,
+ stream,
+ contentType,
+ type,
+ imageIndex,
+ cancellationToken).ConfigureAwait(false);
+ }
}
/// <inheritdoc/>
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index dbacc2a82..97ad1ffbc 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -24,7 +24,7 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
<PackageReference Include="PlaylistsNET" Version="1.3.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
- <PackageReference Include="TMDbLib" Version="1.9.2" />
+ <PackageReference Include="TMDbLib" Version="2.0.0" />
</ItemGroup>
<PropertyGroup>
@@ -34,10 +34,6 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
- <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
- <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
- </PropertyGroup>
-
<!-- Code Analyzers-->
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
diff --git a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs
index fed23df15..f58f5f7a3 100644
--- a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs
@@ -140,7 +140,7 @@ namespace MediaBrowser.Providers.MediaInfo
if (attachmentStream is not null)
{
- return await ExtractAttachment(item, attachmentStream, mediaSource, cancellationToken);
+ return await ExtractAttachment(item, attachmentStream, mediaSource, cancellationToken).ConfigureAwait(false);
}
// Fall back to EmbeddedImage streams
diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
index 751135a2c..81434b862 100644
--- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
+++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
@@ -557,7 +557,7 @@ namespace MediaBrowser.Providers.MediaInfo
CancellationToken cancellationToken)
{
var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
- var externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken);
+ var externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken).ConfigureAwait(false);
var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default ||
options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh;
@@ -611,7 +611,7 @@ namespace MediaBrowser.Providers.MediaInfo
// Rescan
if (downloadedLanguages.Count > 0)
{
- externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, true, cancellationToken);
+ externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, true, cancellationToken).ConfigureAwait(false);
}
}
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs
index 7fb438d8a..7f73afc53 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
@@ -42,11 +43,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
- return new List<ImageType>
- {
- ImageType.Primary,
- ImageType.Disc
- };
+ yield return ImageType.Primary;
+ yield return ImageType.Disc;
}
/// <inheritdoc />
@@ -60,16 +58,19 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
var path = AudioDbAlbumProvider.GetAlbumInfoPath(_config.ApplicationPaths, id);
- await using FileStream jsonStream = AsyncFile.OpenRead(path);
- var obj = await JsonSerializer.DeserializeAsync<AudioDbAlbumProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
-
- if (obj is not null && obj.album is not null && obj.album.Count > 0)
+ FileStream jsonStream = AsyncFile.OpenRead(path);
+ await using (jsonStream.ConfigureAwait(false))
{
- return GetImages(obj.album[0]);
+ var obj = await JsonSerializer.DeserializeAsync<AudioDbAlbumProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+
+ if (obj is not null && obj.album is not null && obj.album.Count > 0)
+ {
+ return GetImages(obj.album[0]);
+ }
}
}
- return new List<RemoteImageInfo>();
+ return Enumerable.Empty<RemoteImageInfo>();
}
private IEnumerable<RemoteImageInfo> GetImages(AudioDbAlbumProvider.Album item)
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
index b92f1f59f..55e2474a5 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
@@ -68,14 +68,17 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
var path = GetAlbumInfoPath(_config.ApplicationPaths, id);
- await using FileStream jsonStream = AsyncFile.OpenRead(path);
- var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
-
- if (obj is not null && obj.album is not null && obj.album.Count > 0)
+ FileStream jsonStream = AsyncFile.OpenRead(path);
+ await using (jsonStream.ConfigureAwait(false))
{
- result.Item = new MusicAlbum();
- result.HasMetadata = true;
- ProcessResult(result.Item, obj.album[0], info.MetadataLanguage);
+ var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+
+ if (obj is not null && obj.album is not null && obj.album.Count > 0)
+ {
+ result.Item = new MusicAlbum();
+ result.HasMetadata = true;
+ ProcessResult(result.Item, obj.album[0], info.MetadataLanguage);
+ }
}
}
@@ -173,13 +176,18 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
Directory.CreateDirectory(Path.GetDirectoryName(path));
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-
- var fileStreamOptions = AsyncFile.WriteOptions;
- fileStreamOptions.Mode = FileMode.Create;
- fileStreamOptions.PreallocationSize = stream.Length;
- await using var xmlFileStream = new FileStream(path, fileStreamOptions);
- await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false);
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ var fileStreamOptions = AsyncFile.WriteOptions;
+ fileStreamOptions.Mode = FileMode.Create;
+ fileStreamOptions.PreallocationSize = stream.Length;
+ var xmlFileStream = new FileStream(path, fileStreamOptions);
+ await using (xmlFileStream.ConfigureAwait(false))
+ {
+ await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false);
+ }
+ }
}
private static string GetAlbumDataPath(IApplicationPaths appPaths, string musicBrainzReleaseGroupId)
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs
index 6d67ad634..b1a285a96 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs
@@ -62,12 +62,15 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id);
- await using FileStream jsonStream = AsyncFile.OpenRead(path);
- var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
-
- if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
+ FileStream jsonStream = AsyncFile.OpenRead(path);
+ await using (jsonStream.ConfigureAwait(false))
{
- return GetImages(obj.artists[0]);
+ var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+
+ if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
+ {
+ return GetImages(obj.artists[0]);
+ }
}
}
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs
index 1565a8c51..f3385b3a9 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs
@@ -67,14 +67,17 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
var path = GetArtistInfoPath(_config.ApplicationPaths, id);
- await using FileStream jsonStream = AsyncFile.OpenRead(path);
- var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
-
- if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
+ FileStream jsonStream = AsyncFile.OpenRead(path);
+ await using (jsonStream.ConfigureAwait(false))
{
- result.Item = new MusicArtist();
- result.HasMetadata = true;
- ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage);
+ var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+
+ if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
+ {
+ result.Item = new MusicArtist();
+ result.HasMetadata = true;
+ ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage);
+ }
}
}
@@ -151,16 +154,21 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-
- var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
- Directory.CreateDirectory(Path.GetDirectoryName(path));
-
- var fileStreamOptions = AsyncFile.WriteOptions;
- fileStreamOptions.Mode = FileMode.Create;
- fileStreamOptions.PreallocationSize = stream.Length;
- await using var xmlFileStream = new FileStream(path, fileStreamOptions);
- await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false);
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+
+ var fileStreamOptions = AsyncFile.WriteOptions;
+ fileStreamOptions.Mode = FileMode.Create;
+ fileStreamOptions.PreallocationSize = stream.Length;
+ var xmlFileStream = new FileStream(path, fileStreamOptions);
+ await using (xmlFileStream.ConfigureAwait(false))
+ {
+ await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false);
+ }
+ }
}
/// <summary>
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
index 3ef94ca93..e4bb4eaea 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
@@ -137,29 +137,31 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var url = OmdbProvider.GetOmdbUrl(urlQuery.ToString());
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-
- if (isSearch)
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
{
- var searchResultList = await JsonSerializer.DeserializeAsync<SearchResultList>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
- if (searchResultList?.Search is not null)
+ if (isSearch)
{
- var resultCount = searchResultList.Search.Count;
- var result = new RemoteSearchResult[resultCount];
- for (var i = 0; i < resultCount; i++)
+ var searchResultList = await JsonSerializer.DeserializeAsync<SearchResultList>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ if (searchResultList?.Search is not null)
{
- result[i] = ResultToMetadataResult(searchResultList.Search[i], searchInfo, indexNumberEnd);
+ var resultCount = searchResultList.Search.Count;
+ var result = new RemoteSearchResult[resultCount];
+ for (var i = 0; i < resultCount; i++)
+ {
+ result[i] = ResultToMetadataResult(searchResultList.Search[i], searchInfo, indexNumberEnd);
+ }
+
+ return result;
}
-
- return result;
}
- }
- else
- {
- var result = await JsonSerializer.DeserializeAsync<SearchResult>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
- if (string.Equals(result?.Response, "true", StringComparison.OrdinalIgnoreCase))
+ else
{
- return new[] { ResultToMetadataResult(result, searchInfo, indexNumberEnd) };
+ var result = await JsonSerializer.DeserializeAsync<SearchResult>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ if (string.Equals(result?.Response, "true", StringComparison.OrdinalIgnoreCase))
+ {
+ return new[] { ResultToMetadataResult(result, searchInfo, indexNumberEnd) };
+ }
}
}
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index 6713a34e6..497437bd8 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
@@ -234,15 +234,21 @@ namespace MediaBrowser.Providers.Plugins.Omdb
internal async Task<RootObject> GetRootObject(string imdbId, CancellationToken cancellationToken)
{
var path = await EnsureItemInfo(imdbId, cancellationToken).ConfigureAwait(false);
- await using var stream = AsyncFile.OpenRead(path);
- return await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ var stream = AsyncFile.OpenRead(path);
+ await using (stream.ConfigureAwait(false))
+ {
+ return await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ }
}
internal async Task<SeasonRootObject> GetSeasonRootObject(string imdbId, int seasonId, CancellationToken cancellationToken)
{
var path = await EnsureSeasonInfo(imdbId, seasonId, cancellationToken).ConfigureAwait(false);
- await using var stream = AsyncFile.OpenRead(path);
- return await JsonSerializer.DeserializeAsync<SeasonRootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ var stream = AsyncFile.OpenRead(path);
+ await using (stream.ConfigureAwait(false))
+ {
+ return await JsonSerializer.DeserializeAsync<SeasonRootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ }
}
/// <summary>Gets OMDB URL.</summary>
@@ -317,8 +323,11 @@ namespace MediaBrowser.Providers.Plugins.Omdb
imdbParam));
var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<RootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false);
- await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
- await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
+ await using (jsonFileStream.ConfigureAwait(false))
+ {
+ await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ }
return path;
}
@@ -357,8 +366,11 @@ namespace MediaBrowser.Providers.Plugins.Omdb
seasonId));
var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<SeasonRootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false);
- await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
- await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
+ await using (jsonFileStream.ConfigureAwait(false))
+ {
+ await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false);
+ }
return path;
}
diff --git a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs
index 4ff9e0247..0fb9d30a6 100644
--- a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs
@@ -138,9 +138,15 @@ namespace MediaBrowser.Providers.Plugins.StudioImages
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
Directory.CreateDirectory(Path.GetDirectoryName(file));
- await using var response = await httpClient.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
- await using var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
- await response.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
+ var response = await httpClient.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
+ await using (response.ConfigureAwait(false))
+ {
+ var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
+ await using (fileStream.ConfigureAwait(false))
+ {
+ await response.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
+ }
+ }
}
return file;
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
index 20898d213..eee3658de 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
@@ -81,8 +81,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
var backdrops = collection.Images.Backdrops;
var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count);
- _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages);
- _tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language, remoteImages);
+ remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
+ remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
return remoteImages;
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
index 01b8bca39..02601d3f5 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
@@ -100,9 +100,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var logos = movie.Images.Logos;
var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count);
- _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages);
- _tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language, remoteImages);
- _tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language, remoteImages);
+ remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
+ remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
+ remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language));
return remoteImages;
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
index aa46d8f25..bc959ee2b 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
@@ -69,12 +69,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
return Enumerable.Empty<RemoteImageInfo>();
}
- var profiles = personResult.Images.Profiles;
- var remoteImages = new List<RemoteImageInfo>(profiles.Count);
-
- _tmdbClientManager.ConvertProfilesToRemoteImageInfo(profiles, language, remoteImages);
-
- return remoteImages;
+ return _tmdbClientManager.ConvertProfilesToRemoteImageInfo(personResult.Images.Profiles, language);
}
/// <inheritdoc />
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
index 127d41cc7..5259faf76 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
@@ -89,11 +89,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
return Enumerable.Empty<RemoteImageInfo>();
}
- var remoteImages = new List<RemoteImageInfo>(stills.Count);
-
- _tmdbClientManager.ConvertStillsToRemoteImageInfo(stills, language, remoteImages);
-
- return remoteImages;
+ return _tmdbClientManager.ConvertStillsToRemoteImageInfo(stills, language);
}
/// <inheritdoc />
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
index fda00537d..b8d1460db 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
@@ -80,11 +80,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
return Enumerable.Empty<RemoteImageInfo>();
}
- var remoteImages = new List<RemoteImageInfo>(posters.Count);
-
- _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages);
-
- return remoteImages;
+ return _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language);
}
/// <inheritdoc />
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
index 9062f1b85..79cb6e86d 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
@@ -83,9 +83,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var logos = series.Images.Logos;
var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count);
- _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages);
- _tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language, remoteImages);
- _tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language, remoteImages);
+ remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
+ remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
+ remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language));
return remoteImages;
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index b56c0d748..c7441bf35 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -531,55 +531,45 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// </summary>
/// <param name="images">The input images.</param>
/// <param name="requestLanguage">The requested language.</param>
- /// <param name="results">The collection to add the remote images into.</param>
- public void ConvertPostersToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results)
- {
- ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.PosterSize, ImageType.Primary, requestLanguage, results);
- }
+ /// <returns>The remote images.</returns>
+ public IEnumerable<RemoteImageInfo> ConvertPostersToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage)
+ => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.PosterSize, ImageType.Primary, requestLanguage);
/// <summary>
/// Converts backdrop <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s.
/// </summary>
/// <param name="images">The input images.</param>
/// <param name="requestLanguage">The requested language.</param>
- /// <param name="results">The collection to add the remote images into.</param>
- public void ConvertBackdropsToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results)
- {
- ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.BackdropSize, ImageType.Backdrop, requestLanguage, results);
- }
+ /// <returns>The remote images.</returns>
+ public IEnumerable<RemoteImageInfo> ConvertBackdropsToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage)
+ => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.BackdropSize, ImageType.Backdrop, requestLanguage);
/// <summary>
/// Converts logo <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s.
/// </summary>
/// <param name="images">The input images.</param>
/// <param name="requestLanguage">The requested language.</param>
- /// <param name="results">The collection to add the remote images into.</param>
- public void ConvertLogosToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results)
- {
- ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.LogoSize, ImageType.Logo, requestLanguage, results);
- }
+ /// <returns>The remote images.</returns>
+ public IEnumerable<RemoteImageInfo> ConvertLogosToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage)
+ => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.LogoSize, ImageType.Logo, requestLanguage);
/// <summary>
/// Converts profile <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s.
/// </summary>
/// <param name="images">The input images.</param>
/// <param name="requestLanguage">The requested language.</param>
- /// <param name="results">The collection to add the remote images into.</param>
- public void ConvertProfilesToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results)
- {
- ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.ProfileSize, ImageType.Primary, requestLanguage, results);
- }
+ /// <returns>The remote images.</returns>
+ public IEnumerable<RemoteImageInfo> ConvertProfilesToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage)
+ => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.ProfileSize, ImageType.Primary, requestLanguage);
/// <summary>
/// Converts still <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s.
/// </summary>
/// <param name="images">The input images.</param>
/// <param name="requestLanguage">The requested language.</param>
- /// <param name="results">The collection to add the remote images into.</param>
- public void ConvertStillsToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results)
- {
- ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.StillSize, ImageType.Primary, requestLanguage, results);
- }
+ /// <returns>The remote images.</returns>
+ public IEnumerable<RemoteImageInfo> ConvertStillsToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage)
+ => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.StillSize, ImageType.Primary, requestLanguage);
/// <summary>
/// Converts <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s.
@@ -588,8 +578,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <param name="size">The size of the image to fetch.</param>
/// <param name="type">The type of the image.</param>
/// <param name="requestLanguage">The requested language.</param>
- /// <param name="results">The collection to add the remote images into.</param>
- private void ConvertToRemoteImageInfo(List<ImageData> images, string size, ImageType type, string requestLanguage, List<RemoteImageInfo> results)
+ /// <returns>The remote images.</returns>
+ private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string size, ImageType type, string requestLanguage)
{
// sizes provided are for original resolution, don't store them when downloading scaled images
var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase);
@@ -598,7 +588,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
{
var image = images[i];
- results.Add(new RemoteImageInfo
+ yield return new RemoteImageInfo
{
Url = GetUrl(size, image.FilePath),
CommunityRating = image.VoteAverage,
@@ -609,7 +599,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
ProviderName = TmdbUtils.ProviderName,
Type = type,
RatingType = RatingType.Score
- });
+ };
}
}
diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
index 1aeffb65f..b1a26cfba 100644
--- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
+++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
@@ -188,10 +188,16 @@ namespace MediaBrowser.Providers.Subtitles
{
var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia;
- await using var stream = response.Stream;
- await using var memoryStream = new MemoryStream();
- await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
- memoryStream.Position = 0;
+ var memoryStream = new MemoryStream();
+ await using (memoryStream.ConfigureAwait(false))
+ {
+ var stream = response.Stream;
+ await using (stream.ConfigureAwait(false))
+ {
+ await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
+ memoryStream.Position = 0;
+ }
+ }
var savePaths = new List<string>();
var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant();
diff --git a/debian/postinst b/debian/postinst
index 47173855f..a15442c76 100644
--- a/debian/postinst
+++ b/debian/postinst
@@ -10,6 +10,8 @@ if [[ -f $DEFAULT_FILE ]]; then
fi
JELLYFIN_USER=${JELLYFIN_USER:-jellyfin}
+RENDER_GROUP=${RENDER_GROUP:-render}
+VIDEO_GROUP=${VIDEO_GROUP:-video}
# Data directories for program data (cache, db), configs, and logs
PROGRAMDATA=${JELLYFIN_DATA_DIRECTORY-/var/lib/$NAME}
@@ -28,6 +30,14 @@ case "$1" in
adduser --system --ingroup ${JELLYFIN_USER} --shell /bin/false ${JELLYFIN_USER} --no-create-home --home ${PROGRAMDATA} \
--gecos "Jellyfin default user" > /dev/null 2>&1
fi
+ # add jellyfin to the render group for hwa
+ if [[ ! -z "$(getent group ${RENDER_GROUP})" ]]; then
+ usermod -aG ${RENDER_GROUP} ${JELLYFIN_USER} > /dev/null 2>&1
+ fi
+ # add jellyfin to the video group for hwa
+ if [[ ! -z "$(getent group ${VIDEO_GROUP})" ]]; then
+ usermod -aG ${VIDEO_GROUP} ${JELLYFIN_USER} > /dev/null 2>&1
+ fi
# ensure $PROGRAMDATA exists
if [[ ! -d $PROGRAMDATA ]]; then
mkdir $PROGRAMDATA
diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64
index f7b7e3025..e02087a52 100644
--- a/deployment/Dockerfile.centos.amd64
+++ b/deployment/Dockerfile.centos.amd64
@@ -13,7 +13,7 @@ RUN yum update -yq \
&& yum install -yq @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git wget
# Install DotNET SDK
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/7fe73a07-575d-4cb4-b2d3-c23d89e5085f/d8b2b7e1c0ed99c1144638d907c6d152/dotnet-sdk-7.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
&& mkdir -p dotnet-sdk \
&& tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
&& ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64
index 666937e5c..6962b6bc1 100644
--- a/deployment/Dockerfile.fedora.amd64
+++ b/deployment/Dockerfile.fedora.amd64
@@ -12,7 +12,7 @@ RUN dnf update -yq \
&& dnf install -yq @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd wget make
# Install DotNET SDK
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/7fe73a07-575d-4cb4-b2d3-c23d89e5085f/d8b2b7e1c0ed99c1144638d907c6d152/dotnet-sdk-7.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
&& mkdir -p dotnet-sdk \
&& tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
&& ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64
index 0ad0132cc..96e3ca403 100644
--- a/deployment/Dockerfile.ubuntu.amd64
+++ b/deployment/Dockerfile.ubuntu.amd64
@@ -17,7 +17,7 @@ RUN apt-get update -yqq \
libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0
# Install dotnet repository
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/7fe73a07-575d-4cb4-b2d3-c23d89e5085f/d8b2b7e1c0ed99c1144638d907c6d152/dotnet-sdk-7.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
&& mkdir -p dotnet-sdk \
&& tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
&& ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64
index 4f7ac2099..f1c536399 100644
--- a/deployment/Dockerfile.ubuntu.arm64
+++ b/deployment/Dockerfile.ubuntu.arm64
@@ -16,7 +16,7 @@ RUN apt-get update -yqq \
mmv build-essential lsb-release
# Install dotnet repository
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/7fe73a07-575d-4cb4-b2d3-c23d89e5085f/d8b2b7e1c0ed99c1144638d907c6d152/dotnet-sdk-7.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
&& mkdir -p dotnet-sdk \
&& tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
&& ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf
index af439e6eb..eaea305d1 100644
--- a/deployment/Dockerfile.ubuntu.armhf
+++ b/deployment/Dockerfile.ubuntu.armhf
@@ -16,7 +16,7 @@ RUN apt-get update -yqq \
mmv build-essential lsb-release
# Install dotnet repository
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/7fe73a07-575d-4cb4-b2d3-c23d89e5085f/d8b2b7e1c0ed99c1144638d907c6d152/dotnet-sdk-7.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
&& mkdir -p dotnet-sdk \
&& tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
&& ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec
index 416d88360..08de71537 100644
--- a/fedora/jellyfin.spec
+++ b/fedora/jellyfin.spec
@@ -139,6 +139,9 @@ getent group jellyfin >/dev/null || groupadd -r jellyfin
getent passwd jellyfin >/dev/null || \
useradd -r -g jellyfin -d %{_sharedstatedir}/jellyfin -s /sbin/nologin \
-c "Jellyfin default user" jellyfin
+# Add jellyfin to the render and video groups for hwa.
+[ ! -z "$(getent group render)" ] && usermod -aG render jellyfin >/dev/null 2>&1
+[ ! -z "$(getent group video)" ] && usermod -aG video jellyfin >/dev/null 2>&1
exit 0
%post server
diff --git a/jellyfin.ruleset b/jellyfin.ruleset
index 71385cee2..b611caa11 100644
--- a/jellyfin.ruleset
+++ b/jellyfin.ruleset
@@ -93,6 +93,8 @@
<Rule Id="CA1845" Action="Error" />
<!-- error on CA1849: Call async methods when in an async method -->
<Rule Id="CA1849" Action="Error" />
+ <!-- error on CA1851: Possible multiple enumerations of IEnumerable collection -->
+ <Rule Id="CA1851" Action="Error" />
<!-- error on CA2016: Forward the CancellationToken parameter to methods that take one
or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token -->
<Rule Id="CA2016" Action="Error" />
@@ -138,6 +140,10 @@
<Rule Id="CA2253" Action="Info" />
<!-- disable warning CA5394: Do not use insecure randomness -->
<Rule Id="CA5394" Action="Info" />
+ <!-- error on CA3003: Review code for file path injection vulnerabilities -->
+ <Rule Id="CA3003" Action="Info" />
+ <!-- error on CA3006: Review code for process command injection vulnerabilities -->
+ <Rule Id="CA3006" Action="Info" />
<!-- disable warning CA1054: Change the type of parameter url from string to System.Uri -->
<Rule Id="CA1054" Action="None" />
diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
index dac3d0a61..c686b229a 100644
--- a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
+++ b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
@@ -12,7 +12,7 @@
</PropertyGroup>
<ItemGroup>
- <Compile Include="..\SharedVersion.cs" />
+ <Compile Include="..\..\SharedVersion.cs" />
</ItemGroup>
<ItemGroup>
@@ -24,9 +24,9 @@
</ItemGroup>
<ItemGroup>
- <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
- <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
- <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
+ <ProjectReference Include="..\..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
+ <ProjectReference Include="..\..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
+ <ProjectReference Include="..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
</ItemGroup>
<!-- Code analysers-->
diff --git a/src/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs b/src/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs
new file mode 100644
index 000000000..e2e90be47
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs
@@ -0,0 +1,35 @@
+using System;
+using MediaBrowser.Model.Drawing;
+using SkiaSharp;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Static helper class used to draw percentage-played indicators on images.
+/// </summary>
+public static class PercentPlayedDrawer
+{
+ private const int IndicatorHeight = 8;
+
+ /// <summary>
+ /// Draw a percentage played indicator on a canvas.
+ /// </summary>
+ /// <param name="canvas">The canvas to draw the indicator on.</param>
+ /// <param name="imageSize">The size of the image being drawn on.</param>
+ /// <param name="percent">The percentage played to display with the indicator.</param>
+ public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent)
+ {
+ using var paint = new SKPaint();
+ var endX = imageSize.Width - 1;
+ var endY = imageSize.Height - 1;
+
+ paint.Color = SKColor.Parse("#99000000");
+ paint.Style = SKPaintStyle.Fill;
+ canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint);
+
+ double foregroundWidth = (endX * percent) / 100;
+
+ paint.Color = SKColor.Parse("#FF00A4DC");
+ canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint);
+ }
+}
diff --git a/src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs b/src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs
new file mode 100644
index 000000000..5bb42fb99
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs
@@ -0,0 +1,47 @@
+using MediaBrowser.Model.Drawing;
+using SkiaSharp;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Static helper class for drawing 'played' indicators.
+/// </summary>
+public static class PlayedIndicatorDrawer
+{
+ private const int OffsetFromTopRightCorner = 38;
+
+ /// <summary>
+ /// Draw a 'played' indicator in the top right corner of a canvas.
+ /// </summary>
+ /// <param name="canvas">The canvas to draw the indicator on.</param>
+ /// <param name="imageSize">
+ /// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the
+ /// indicator.
+ /// </param>
+ public static void DrawPlayedIndicator(SKCanvas canvas, ImageDimensions imageSize)
+ {
+ var x = imageSize.Width - OffsetFromTopRightCorner;
+
+ using var paint = new SKPaint
+ {
+ Color = SKColor.Parse("#CC00A4DC"),
+ Style = SKPaintStyle.Fill
+ };
+
+ canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
+
+ paint.Color = new SKColor(255, 255, 255, 255);
+ paint.TextSize = 30;
+ paint.IsAntialias = true;
+
+ // or:
+ // var emojiChar = 0x1F680;
+ const string Text = "✔️";
+ var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32);
+
+ // ask the font manager for a font with that character
+ paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar);
+
+ canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint);
+ }
+}
diff --git a/Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs b/src/Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs
index e7db09449..e7db09449 100644
--- a/Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs
+++ b/src/Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs
diff --git a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs b/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs
new file mode 100644
index 000000000..581fa000d
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs
@@ -0,0 +1,44 @@
+using System.Globalization;
+using SkiaSharp;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Represents errors that occur during interaction with Skia codecs.
+/// </summary>
+public class SkiaCodecException : SkiaException
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SkiaCodecException" /> class.
+ /// </summary>
+ /// <param name="result">The non-successful codec result returned by Skia.</param>
+ public SkiaCodecException(SKCodecResult result)
+ {
+ CodecResult = result;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SkiaCodecException" /> class
+ /// with a specified error message.
+ /// </summary>
+ /// <param name="result">The non-successful codec result returned by Skia.</param>
+ /// <param name="message">The message that describes the error.</param>
+ public SkiaCodecException(SKCodecResult result, string message)
+ : base(message)
+ {
+ CodecResult = result;
+ }
+
+ /// <summary>
+ /// Gets the non-successful codec result returned by Skia.
+ /// </summary>
+ public SKCodecResult CodecResult { get; }
+
+ /// <inheritdoc />
+ public override string ToString()
+ => string.Format(
+ CultureInfo.InvariantCulture,
+ "Non-success codec result: {0}\n{1}",
+ CodecResult,
+ base.ToString());
+}
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
new file mode 100644
index 000000000..ddb8a98d4
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -0,0 +1,544 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using BlurHashSharp.SkiaSharp;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Model.Drawing;
+using Microsoft.Extensions.Logging;
+using SkiaSharp;
+using SKSvg = SkiaSharp.Extended.Svg.SKSvg;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images.
+/// </summary>
+public class SkiaEncoder : IImageEncoder
+{
+ private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
+
+ private readonly ILogger<SkiaEncoder> _logger;
+ private readonly IApplicationPaths _appPaths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
+ /// </summary>
+ /// <param name="logger">The application logger.</param>
+ /// <param name="appPaths">The application paths.</param>
+ public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths)
+ {
+ _logger = logger;
+ _appPaths = appPaths;
+ }
+
+ /// <inheritdoc/>
+ public string Name => "Skia";
+
+ /// <inheritdoc/>
+ public bool SupportsImageCollageCreation => true;
+
+ /// <inheritdoc/>
+ public bool SupportsImageEncoding => true;
+
+ /// <inheritdoc/>
+ public IReadOnlyCollection<string> SupportedInputFormats =>
+ new HashSet<string>(StringComparer.OrdinalIgnoreCase)
+ {
+ "jpeg",
+ "jpg",
+ "png",
+ "dng",
+ "webp",
+ "gif",
+ "bmp",
+ "ico",
+ "astc",
+ "ktx",
+ "pkm",
+ "wbmp",
+ // TODO: check if these are supported on multiple platforms
+ // https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454
+ // working on windows at least
+ "cr2",
+ "nef",
+ "arw"
+ };
+
+ /// <inheritdoc/>
+ public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
+ => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png };
+
+ /// <summary>
+ /// Check if the native lib is available.
+ /// </summary>
+ /// <returns>True if the native lib is available, otherwise false.</returns>
+ public static bool IsNativeLibAvailable()
+ {
+ try
+ {
+ // test an operation that requires the native library
+ SKPMColor.PreMultiply(SKColors.Black);
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ /// <summary>
+ /// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
+ /// </summary>
+ /// <param name="selectedFormat">The format to convert.</param>
+ /// <returns>The converted format.</returns>
+ public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat)
+ {
+ return selectedFormat switch
+ {
+ ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
+ ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
+ ImageFormat.Gif => SKEncodedImageFormat.Gif,
+ ImageFormat.Webp => SKEncodedImageFormat.Webp,
+ _ => SKEncodedImageFormat.Png
+ };
+ }
+
+ /// <inheritdoc />
+ /// <exception cref="FileNotFoundException">The path is not valid.</exception>
+ public ImageDimensions GetImageSize(string path)
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException("File not found", path);
+ }
+
+ var extension = Path.GetExtension(path.AsSpan());
+ if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
+ {
+ var svg = new SKSvg();
+ svg.Load(path);
+ return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height));
+ }
+
+ using var codec = SKCodec.Create(path, out SKCodecResult result);
+ switch (result)
+ {
+ case SKCodecResult.Success:
+ var info = codec.Info;
+ return new ImageDimensions(info.Width, info.Height);
+ case SKCodecResult.Unimplemented:
+ _logger.LogDebug("Image format not supported: {FilePath}", path);
+ return new ImageDimensions(0, 0);
+ default:
+ _logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result);
+ return new ImageDimensions(0, 0);
+ }
+ }
+
+ /// <inheritdoc />
+ /// <exception cref="ArgumentNullException">The path is null.</exception>
+ /// <exception cref="FileNotFoundException">The path is not valid.</exception>
+ /// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception>
+ public string GetImageBlurHash(int xComp, int yComp, string path)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(path);
+
+ var extension = Path.GetExtension(path.AsSpan()).TrimStart('.');
+ if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path);
+ return string.Empty;
+ }
+
+ // Any larger than 128x128 is too slow and there's no visually discernible difference
+ return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128);
+ }
+
+ private bool RequiresSpecialCharacterHack(string path)
+ {
+ for (int i = 0; i < path.Length; i++)
+ {
+ if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter)
+ {
+ return true;
+ }
+ }
+
+ return path.HasDiacritics();
+ }
+
+ private string NormalizePath(string path)
+ {
+ if (!RequiresSpecialCharacterHack(path))
+ {
+ return path;
+ }
+
+ var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path));
+ var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid.");
+ Directory.CreateDirectory(directory);
+ File.Copy(path, tempPath, true);
+
+ return tempPath;
+ }
+
+ private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation)
+ {
+ if (!orientation.HasValue)
+ {
+ return SKEncodedOrigin.TopLeft;
+ }
+
+ return orientation.Value switch
+ {
+ ImageOrientation.TopRight => SKEncodedOrigin.TopRight,
+ ImageOrientation.RightTop => SKEncodedOrigin.RightTop,
+ ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom,
+ ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop,
+ ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom,
+ ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight,
+ ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft,
+ _ => SKEncodedOrigin.TopLeft
+ };
+ }
+
+ /// <summary>
+ /// Decode an image.
+ /// </summary>
+ /// <param name="path">The filepath of the image to decode.</param>
+ /// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param>
+ /// <param name="orientation">The orientation of the image.</param>
+ /// <param name="origin">The detected origin of the image.</param>
+ /// <returns>The resulting bitmap of the image.</returns>
+ internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin)
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException("File not found", path);
+ }
+
+ var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path));
+
+ if (requiresTransparencyHack || forceCleanBitmap)
+ {
+ using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res);
+ if (res != SKCodecResult.Success)
+ {
+ origin = GetSKEncodedOrigin(orientation);
+ return null;
+ }
+
+ // create the bitmap
+ var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
+
+ // decode
+ _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
+
+ origin = codec.EncodedOrigin;
+
+ return bitmap;
+ }
+
+ var resultBitmap = SKBitmap.Decode(NormalizePath(path));
+
+ if (resultBitmap is null)
+ {
+ return Decode(path, true, orientation, out origin);
+ }
+
+ // If we have to resize these they often end up distorted
+ if (resultBitmap.ColorType == SKColorType.Gray8)
+ {
+ using (resultBitmap)
+ {
+ return Decode(path, true, orientation, out origin);
+ }
+ }
+
+ origin = SKEncodedOrigin.TopLeft;
+ return resultBitmap;
+ }
+
+ private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation)
+ {
+ if (autoOrient)
+ {
+ var bitmap = Decode(path, true, orientation, out var origin);
+
+ if (bitmap is not null && origin != SKEncodedOrigin.TopLeft)
+ {
+ using (bitmap)
+ {
+ return OrientImage(bitmap, origin);
+ }
+ }
+
+ return bitmap;
+ }
+
+ return Decode(path, false, orientation, out _);
+ }
+
+ private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
+ {
+ var needsFlip = origin == SKEncodedOrigin.LeftBottom
+ || origin == SKEncodedOrigin.LeftTop
+ || origin == SKEncodedOrigin.RightBottom
+ || origin == SKEncodedOrigin.RightTop;
+ var rotated = needsFlip
+ ? new SKBitmap(bitmap.Height, bitmap.Width)
+ : new SKBitmap(bitmap.Width, bitmap.Height);
+ using var surface = new SKCanvas(rotated);
+ var midX = (float)rotated.Width / 2;
+ var midY = (float)rotated.Height / 2;
+
+ switch (origin)
+ {
+ case SKEncodedOrigin.TopRight:
+ surface.Scale(-1, 1, midX, midY);
+ break;
+ case SKEncodedOrigin.BottomRight:
+ surface.RotateDegrees(180, midX, midY);
+ break;
+ case SKEncodedOrigin.BottomLeft:
+ surface.Scale(1, -1, midX, midY);
+ break;
+ case SKEncodedOrigin.LeftTop:
+ surface.Translate(0, -rotated.Height);
+ surface.Scale(1, -1, midX, midY);
+ surface.RotateDegrees(-90);
+ break;
+ case SKEncodedOrigin.RightTop:
+ surface.Translate(rotated.Width, 0);
+ surface.RotateDegrees(90);
+ break;
+ case SKEncodedOrigin.RightBottom:
+ surface.Translate(rotated.Width, 0);
+ surface.Scale(1, -1, midX, midY);
+ surface.RotateDegrees(90);
+ break;
+ case SKEncodedOrigin.LeftBottom:
+ surface.Translate(0, rotated.Height);
+ surface.RotateDegrees(-90);
+ break;
+ }
+
+ surface.DrawBitmap(bitmap, 0, 0);
+ return rotated;
+ }
+
+ /// <summary>
+ /// Resizes an image on the CPU, by utilizing a surface and canvas.
+ ///
+ /// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect.
+ /// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP](https://docs.gimp.org/2.10/en/gimp-filter-convolution-matrix.html).
+ /// </summary>
+ /// <param name="source">The source bitmap.</param>
+ /// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param>
+ /// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param>
+ /// <param name="isDither">This enables dithering on the SKPaint instance.</param>
+ /// <returns>The resized image.</returns>
+ internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false)
+ {
+ using var surface = SKSurface.Create(targetInfo);
+ using var canvas = surface.Canvas;
+ using var paint = new SKPaint
+ {
+ FilterQuality = SKFilterQuality.High,
+ IsAntialias = isAntialias,
+ IsDither = isDither
+ };
+
+ var kernel = new float[9]
+ {
+ 0, -.1f, 0,
+ -.1f, 1.4f, -.1f,
+ 0, -.1f, 0,
+ };
+
+ var kernelSize = new SKSizeI(3, 3);
+ var kernelOffset = new SKPointI(1, 1);
+
+ paint.ImageFilter = SKImageFilter.CreateMatrixConvolution(
+ kernelSize,
+ kernel,
+ 1f,
+ 0f,
+ kernelOffset,
+ SKShaderTileMode.Clamp,
+ true);
+
+ canvas.DrawBitmap(
+ source,
+ SKRect.Create(0, 0, source.Width, source.Height),
+ SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
+ paint);
+
+ return surface.Snapshot();
+ }
+
+ /// <inheritdoc/>
+ public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(inputPath);
+ ArgumentException.ThrowIfNullOrEmpty(outputPath);
+
+ var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.');
+ if (!SupportedInputFormats.Contains(inputFormat, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogDebug("Unable to encode image due to unsupported format: {ImagePath}", inputPath);
+ return inputPath;
+ }
+
+ var skiaOutputFormat = GetImageFormat(outputFormat);
+
+ var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
+ var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer);
+ var blur = options.Blur ?? 0;
+ var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
+
+ using var bitmap = GetBitmap(inputPath, autoOrient, orientation);
+ if (bitmap is null)
+ {
+ throw new InvalidDataException($"Skia unable to read image {inputPath}");
+ }
+
+ var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
+
+ if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
+ {
+ // Just spit out the original file if all the options are default
+ return inputPath;
+ }
+
+ var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
+
+ var width = newImageSize.Width;
+ var height = newImageSize.Height;
+
+ // scale image (the FromImage creates a copy)
+ var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace);
+ using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo));
+
+ // If all we're doing is resizing then we can stop now
+ if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator)
+ {
+ var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+ Directory.CreateDirectory(outputDirectory);
+ using var outputStream = new SKFileWStream(outputPath);
+ using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels());
+ resizedBitmap.Encode(outputStream, skiaOutputFormat, quality);
+ return outputPath;
+ }
+
+ // create bitmap to use for canvas drawing used to draw into bitmap
+ using var saveBitmap = new SKBitmap(width, height);
+ using var canvas = new SKCanvas(saveBitmap);
+ // set background color if present
+ if (hasBackgroundColor)
+ {
+ canvas.Clear(SKColor.Parse(options.BackgroundColor));
+ }
+
+ // Add blur if option is present
+ if (blur > 0)
+ {
+ // create image from resized bitmap to apply blur
+ using var paint = new SKPaint();
+ using var filter = SKImageFilter.CreateBlur(blur, blur);
+ paint.ImageFilter = filter;
+ canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint);
+ }
+ else
+ {
+ // draw resized bitmap onto canvas
+ canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height));
+ }
+
+ // If foreground layer present then draw
+ if (hasForegroundColor)
+ {
+ if (!double.TryParse(options.ForegroundLayer, out double opacity))
+ {
+ opacity = .4;
+ }
+
+ canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
+ }
+
+ if (hasIndicator)
+ {
+ DrawIndicator(canvas, width, height, options);
+ }
+
+ var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+ Directory.CreateDirectory(directory);
+ using (var outputStream = new SKFileWStream(outputPath))
+ {
+ using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels()))
+ {
+ pixmap.Encode(outputStream, skiaOutputFormat, quality);
+ }
+ }
+
+ return outputPath;
+ }
+
+ /// <inheritdoc/>
+ public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+ {
+ double ratio = (double)options.Width / options.Height;
+
+ if (ratio >= 1.4)
+ {
+ new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height, libraryName);
+ }
+ else if (ratio >= .9)
+ {
+ new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
+ }
+ else
+ {
+ // TODO: Create Poster collage capability
+ new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
+ }
+ }
+
+ /// <inheritdoc />
+ public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
+ {
+ var splashBuilder = new SplashscreenBuilder(this);
+ var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
+ splashBuilder.GenerateSplash(posters, backdrops, outputPath);
+ }
+
+ private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
+ {
+ try
+ {
+ var currentImageSize = new ImageDimensions(imageWidth, imageHeight);
+
+ if (options.AddPlayedIndicator)
+ {
+ PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize);
+ }
+ else if (options.UnplayedCount.HasValue)
+ {
+ UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value);
+ }
+
+ if (options.PercentPlayed > 0)
+ {
+ PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error drawing indicator overlay");
+ }
+ }
+}
diff --git a/src/Jellyfin.Drawing.Skia/SkiaException.cs b/src/Jellyfin.Drawing.Skia/SkiaException.cs
new file mode 100644
index 000000000..d0e69d42c
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/SkiaException.cs
@@ -0,0 +1,38 @@
+using System;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Represents errors that occur during interaction with Skia.
+/// </summary>
+public class SkiaException : Exception
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SkiaException"/> class.
+ /// </summary>
+ public SkiaException()
+ {
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message.
+ /// </summary>
+ /// <param name="message">The message that describes the error.</param>
+ public SkiaException(string message) : base(message)
+ {
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message and a
+ /// reference to the inner exception that is the cause of this exception.
+ /// </summary>
+ /// <param name="message">The error message that explains the reason for the exception.</param>
+ /// <param name="innerException">
+ /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if
+ /// no inner exception is specified.
+ /// </param>
+ public SkiaException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/Jellyfin.Drawing.Skia/SkiaHelper.cs b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs
new file mode 100644
index 000000000..00d224da9
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs
@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+using SkiaSharp;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Class containing helper methods for working with SkiaSharp.
+/// </summary>
+public static class SkiaHelper
+{
+ /// <summary>
+ /// Gets the next valid image as a bitmap.
+ /// </summary>
+ /// <param name="skiaEncoder">The current skia encoder.</param>
+ /// <param name="paths">The list of image paths.</param>
+ /// <param name="currentIndex">The current checked index.</param>
+ /// <param name="newIndex">The new index.</param>
+ /// <returns>A valid bitmap, or null if no bitmap exists after <c>currentIndex</c>.</returns>
+ public static SKBitmap? GetNextValidImage(SkiaEncoder skiaEncoder, IReadOnlyList<string> paths, int currentIndex, out int newIndex)
+ {
+ var imagesTested = new Dictionary<int, int>();
+ SKBitmap? bitmap = null;
+
+ while (imagesTested.Count < paths.Count)
+ {
+ if (currentIndex >= paths.Count)
+ {
+ currentIndex = 0;
+ }
+
+ bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _);
+
+ imagesTested[currentIndex] = 0;
+
+ currentIndex++;
+
+ if (bitmap is not null)
+ {
+ break;
+ }
+ }
+
+ newIndex = currentIndex;
+ return bitmap;
+ }
+}
diff --git a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs
new file mode 100644
index 000000000..990556623
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using SkiaSharp;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Used to build the splashscreen.
+/// </summary>
+public class SplashscreenBuilder
+{
+ private const int FinalWidth = 1920;
+ private const int FinalHeight = 1080;
+ // generated collage resolution should be higher than the final resolution
+ private const int WallWidth = FinalWidth * 3;
+ private const int WallHeight = FinalHeight * 2;
+ private const int Rows = 6;
+ private const int Spacing = 20;
+
+ private readonly SkiaEncoder _skiaEncoder;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class.
+ /// </summary>
+ /// <param name="skiaEncoder">The SkiaEncoder.</param>
+ public SplashscreenBuilder(SkiaEncoder skiaEncoder)
+ {
+ _skiaEncoder = skiaEncoder;
+ }
+
+ /// <summary>
+ /// Generate a splashscreen.
+ /// </summary>
+ /// <param name="posters">The poster paths.</param>
+ /// <param name="backdrops">The landscape paths.</param>
+ /// <param name="outputPath">The output path.</param>
+ public void GenerateSplash(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops, string outputPath)
+ {
+ using var wall = GenerateCollage(posters, backdrops);
+ using var transformed = Transform3D(wall);
+
+ using var outputStream = new SKFileWStream(outputPath);
+ using var pixmap = new SKPixmap(new SKImageInfo(FinalWidth, FinalHeight), transformed.GetPixels());
+ pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(outputPath), 90);
+ }
+
+ /// <summary>
+ /// Generates a collage of posters and landscape pictures.
+ /// </summary>
+ /// <param name="posters">The poster paths.</param>
+ /// <param name="backdrops">The landscape paths.</param>
+ /// <returns>The created collage as a bitmap.</returns>
+ private SKBitmap GenerateCollage(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
+ {
+ var posterIndex = 0;
+ var backdropIndex = 0;
+
+ var bitmap = new SKBitmap(WallWidth, WallHeight);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.Black);
+
+ int posterHeight = WallHeight / 6;
+
+ for (int i = 0; i < Rows; i++)
+ {
+ int imageCounter = Random.Shared.Next(0, 5);
+ int currentWidthPos = i * 75;
+ int currentHeight = i * (posterHeight + Spacing);
+
+ while (currentWidthPos < WallWidth)
+ {
+ SKBitmap? currentImage;
+
+ switch (imageCounter)
+ {
+ case 0:
+ case 2:
+ case 3:
+ currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex);
+ posterIndex = newPosterIndex;
+ break;
+ default:
+ currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex);
+ backdropIndex = newBackdropIndex;
+ break;
+ }
+
+ if (currentImage is null)
+ {
+ throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!");
+ }
+
+ // resize to the same aspect as the original
+ var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height);
+ using var resizedBitmap = new SKBitmap(imageWidth, posterHeight);
+ currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High);
+
+ // draw on canvas
+ canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight);
+
+ currentWidthPos += imageWidth + Spacing;
+
+ currentImage.Dispose();
+
+ if (imageCounter >= 4)
+ {
+ imageCounter = 0;
+ }
+ else
+ {
+ imageCounter++;
+ }
+ }
+ }
+
+ return bitmap;
+ }
+
+ /// <summary>
+ /// Transform the collage in 3D space.
+ /// </summary>
+ /// <param name="input">The bitmap to transform.</param>
+ /// <returns>The transformed image.</returns>
+ private SKBitmap Transform3D(SKBitmap input)
+ {
+ var bitmap = new SKBitmap(FinalWidth, FinalHeight);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.Black);
+ var matrix = new SKMatrix
+ {
+ ScaleX = 0.324108899f,
+ ScaleY = 0.563934922f,
+ SkewX = -0.244337708f,
+ SkewY = 0.0377609022f,
+ TransX = 42.0407715f,
+ TransY = -198.104706f,
+ Persp0 = -9.08959337E-05f,
+ Persp1 = 6.85242048E-05f,
+ Persp2 = 0.988209724f
+ };
+
+ canvas.SetMatrix(matrix);
+ canvas.DrawBitmap(input, 0, 0);
+
+ return bitmap;
+ }
+}
diff --git a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
new file mode 100644
index 000000000..eee24c423
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.RegularExpressions;
+using SkiaSharp;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Used to build collages of multiple images arranged in vertical strips.
+/// </summary>
+public class StripCollageBuilder
+{
+ private readonly SkiaEncoder _skiaEncoder;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StripCollageBuilder"/> class.
+ /// </summary>
+ /// <param name="skiaEncoder">The encoder to use for building collages.</param>
+ public StripCollageBuilder(SkiaEncoder skiaEncoder)
+ {
+ _skiaEncoder = skiaEncoder;
+ }
+
+ /// <summary>
+ /// Check which format an image has been encoded with using its filename extension.
+ /// </summary>
+ /// <param name="outputPath">The path to the image to get the format for.</param>
+ /// <returns>The image format.</returns>
+ public static SKEncodedImageFormat GetEncodedFormat(string outputPath)
+ {
+ ArgumentNullException.ThrowIfNull(outputPath);
+
+ var ext = Path.GetExtension(outputPath);
+
+ if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase))
+ {
+ return SKEncodedImageFormat.Jpeg;
+ }
+
+ if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase))
+ {
+ return SKEncodedImageFormat.Webp;
+ }
+
+ if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase))
+ {
+ return SKEncodedImageFormat.Gif;
+ }
+
+ if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase))
+ {
+ return SKEncodedImageFormat.Bmp;
+ }
+
+ // default to png
+ return SKEncodedImageFormat.Png;
+ }
+
+ /// <summary>
+ /// Create a square collage.
+ /// </summary>
+ /// <param name="paths">The paths of the images to use in the collage.</param>
+ /// <param name="outputPath">The path at which to place the resulting collage image.</param>
+ /// <param name="width">The desired width of the collage.</param>
+ /// <param name="height">The desired height of the collage.</param>
+ public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height)
+ {
+ using var bitmap = BuildSquareCollageBitmap(paths, width, height);
+ using var outputStream = new SKFileWStream(outputPath);
+ using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
+ pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
+ }
+
+ /// <summary>
+ /// Create a thumb collage.
+ /// </summary>
+ /// <param name="paths">The paths of the images to use in the collage.</param>
+ /// <param name="outputPath">The path at which to place the resulting image.</param>
+ /// <param name="width">The desired width of the collage.</param>
+ /// <param name="height">The desired height of the collage.</param>
+ /// <param name="libraryName">The name of the library to draw on the collage.</param>
+ public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? libraryName)
+ {
+ using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName);
+ using var outputStream = new SKFileWStream(outputPath);
+ using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
+ pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
+ }
+
+ private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName)
+ {
+ var bitmap = new SKBitmap(width, height);
+
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.Black);
+
+ using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
+ if (backdrop is null)
+ {
+ return bitmap;
+ }
+
+ // resize to the same aspect as the original
+ var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width);
+ using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace));
+ // draw the backdrop
+ canvas.DrawImage(residedBackdrop, 0, 0);
+
+ // draw shadow rectangle
+ using var paintColor = new SKPaint
+ {
+ Color = SKColors.Black.WithAlpha(0x78),
+ Style = SKPaintStyle.Fill
+ };
+ canvas.DrawRect(0, 0, width, height, paintColor);
+
+ var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright);
+
+ // use the system fallback to find a typeface for the given CJK character
+ var nonCjkPattern = @"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]";
+ var filteredName = Regex.Replace(libraryName ?? string.Empty, nonCjkPattern, string.Empty);
+ if (!string.IsNullOrEmpty(filteredName))
+ {
+ typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]);
+ }
+
+ // draw library name
+ using var textPaint = new SKPaint
+ {
+ Color = SKColors.White,
+ Style = SKPaintStyle.Fill,
+ TextSize = 112,
+ TextAlign = SKTextAlign.Center,
+ Typeface = typeFace,
+ IsAntialias = true
+ };
+
+ // scale down text to 90% of the width if text is larger than 95% of the width
+ var textWidth = textPaint.MeasureText(libraryName);
+ if (textWidth > width * 0.95)
+ {
+ textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth;
+ }
+
+ canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint);
+
+ return bitmap;
+ }
+
+ private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height)
+ {
+ var bitmap = new SKBitmap(width, height);
+ var imageIndex = 0;
+ var cellWidth = width / 2;
+ var cellHeight = height / 2;
+
+ using var canvas = new SKCanvas(bitmap);
+ for (var x = 0; x < 2; x++)
+ {
+ for (var y = 0; y < 2; y++)
+ {
+ using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex);
+ imageIndex = newIndex;
+
+ if (currentBitmap is null)
+ {
+ continue;
+ }
+
+ // Scale image. The FromBitmap creates a copy
+ var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace);
+ using var resizedBitmap = SKBitmap.FromImage(SkiaEncoder.ResizeImage(currentBitmap, imageInfo));
+
+ // draw this image into the strip at the next position
+ var xPos = x * cellWidth;
+ var yPos = y * cellHeight;
+ canvas.DrawBitmap(resizedBitmap, xPos, yPos);
+ }
+ }
+
+ return bitmap;
+ }
+}
diff --git a/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs b/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs
new file mode 100644
index 000000000..456b84b8c
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs
@@ -0,0 +1,63 @@
+using System.Globalization;
+using MediaBrowser.Model.Drawing;
+using SkiaSharp;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Static helper class for drawing unplayed count indicators.
+/// </summary>
+public static class UnplayedCountIndicator
+{
+ /// <summary>
+ /// The x-offset used when drawing an unplayed count indicator.
+ /// </summary>
+ private const int OffsetFromTopRightCorner = 38;
+
+ /// <summary>
+ /// Draw an unplayed count indicator in the top right corner of a canvas.
+ /// </summary>
+ /// <param name="canvas">The canvas to draw the indicator on.</param>
+ /// <param name="imageSize">
+ /// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the
+ /// indicator.
+ /// </param>
+ /// <param name="count">The number to draw in the indicator.</param>
+ public static void DrawUnplayedCountIndicator(SKCanvas canvas, ImageDimensions imageSize, int count)
+ {
+ var x = imageSize.Width - OffsetFromTopRightCorner;
+ var text = count.ToString(CultureInfo.InvariantCulture);
+
+ using var paint = new SKPaint
+ {
+ Color = SKColor.Parse("#CC00A4DC"),
+ Style = SKPaintStyle.Fill
+ };
+
+ canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
+
+ paint.Color = new SKColor(255, 255, 255, 255);
+ paint.TextSize = 24;
+ paint.IsAntialias = true;
+
+ var y = OffsetFromTopRightCorner + 9;
+
+ if (text.Length == 1)
+ {
+ x -= 7;
+ }
+
+ if (text.Length == 2)
+ {
+ x -= 13;
+ }
+ else if (text.Length >= 3)
+ {
+ x -= 15;
+ y -= 2;
+ paint.TextSize = 18;
+ }
+
+ canvas.DrawText(text, x, y, paint);
+ }
+}
diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs
new file mode 100644
index 000000000..b381c9ae7
--- /dev/null
+++ b/src/Jellyfin.Drawing/ImageProcessor.cs
@@ -0,0 +1,568 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Text;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using Microsoft.Extensions.Logging;
+using Photo = MediaBrowser.Controller.Entities.Photo;
+
+namespace Jellyfin.Drawing;
+
+/// <summary>
+/// Class ImageProcessor.
+/// </summary>
+public sealed class ImageProcessor : IImageProcessor, IDisposable
+{
+ // Increment this when there's a change requiring caches to be invalidated
+ private const char Version = '3';
+
+ private static readonly HashSet<string> _transparentImageTypes
+ = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
+
+ private readonly ILogger<ImageProcessor> _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IServerApplicationPaths _appPaths;
+ private readonly IImageEncoder _imageEncoder;
+ private readonly IMediaEncoder _mediaEncoder;
+
+ private bool _disposed;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ImageProcessor"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="appPaths">The server application paths.</param>
+ /// <param name="fileSystem">The filesystem.</param>
+ /// <param name="imageEncoder">The image encoder.</param>
+ /// <param name="mediaEncoder">The media encoder.</param>
+ public ImageProcessor(
+ ILogger<ImageProcessor> logger,
+ IServerApplicationPaths appPaths,
+ IFileSystem fileSystem,
+ IImageEncoder imageEncoder,
+ IMediaEncoder mediaEncoder)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _imageEncoder = imageEncoder;
+ _mediaEncoder = mediaEncoder;
+ _appPaths = appPaths;
+ }
+
+ private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
+
+ /// <inheritdoc />
+ public IReadOnlyCollection<string> SupportedInputFormats =>
+ new HashSet<string>(StringComparer.OrdinalIgnoreCase)
+ {
+ "tiff",
+ "tif",
+ "jpeg",
+ "jpg",
+ "png",
+ "aiff",
+ "cr2",
+ "crw",
+ "nef",
+ "orf",
+ "pef",
+ "arw",
+ "webp",
+ "gif",
+ "bmp",
+ "erf",
+ "raf",
+ "rw2",
+ "nrw",
+ "dng",
+ "ico",
+ "astc",
+ "ktx",
+ "pkm",
+ "wbmp"
+ };
+
+ /// <inheritdoc />
+ public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
+
+ /// <inheritdoc />
+ public async Task ProcessImage(ImageProcessingOptions options, Stream toStream)
+ {
+ var file = await ProcessImage(options).ConfigureAwait(false);
+ using (var fileStream = AsyncFile.OpenRead(file.Path))
+ {
+ await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
+ }
+ }
+
+ /// <inheritdoc />
+ public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
+ => _imageEncoder.SupportedOutputFormats;
+
+ /// <inheritdoc />
+ public bool SupportsTransparency(string path)
+ => _transparentImageTypes.Contains(Path.GetExtension(path));
+
+ /// <inheritdoc />
+ public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options)
+ {
+ ItemImageInfo originalImage = options.Image;
+ BaseItem item = options.Item;
+
+ string originalImagePath = originalImage.Path;
+ DateTime dateModified = originalImage.DateModified;
+ ImageDimensions? originalImageSize = null;
+ if (originalImage.Width > 0 && originalImage.Height > 0)
+ {
+ originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
+ }
+
+ var mimeType = MimeTypes.GetMimeType(originalImagePath);
+ if (!_imageEncoder.SupportsImageEncoding)
+ {
+ return (originalImagePath, mimeType, dateModified);
+ }
+
+ var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
+ originalImagePath = supportedImageInfo.Path;
+
+ // Original file doesn't exist, or original file is gif.
+ if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase))
+ {
+ return (originalImagePath, mimeType, dateModified);
+ }
+
+ dateModified = supportedImageInfo.DateModified;
+ bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
+
+ bool autoOrient = false;
+ ImageOrientation? orientation = null;
+ if (item is Photo photo)
+ {
+ if (photo.Orientation.HasValue)
+ {
+ if (photo.Orientation.Value != ImageOrientation.TopLeft)
+ {
+ autoOrient = true;
+ orientation = photo.Orientation;
+ }
+ }
+ else
+ {
+ // Orientation unknown, so do it
+ autoOrient = true;
+ orientation = photo.Orientation;
+ }
+ }
+
+ if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation))
+ {
+ // Just spit out the original file if all the options are default
+ return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
+ }
+
+ int quality = options.Quality;
+
+ ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
+ string cacheFilePath = GetCacheFilePath(
+ originalImagePath,
+ options.Width,
+ options.Height,
+ options.MaxWidth,
+ options.MaxHeight,
+ options.FillWidth,
+ options.FillHeight,
+ quality,
+ dateModified,
+ outputFormat,
+ options.AddPlayedIndicator,
+ options.PercentPlayed,
+ options.UnplayedCount,
+ options.Blur,
+ options.BackgroundColor,
+ options.ForegroundLayer);
+
+ try
+ {
+ if (!File.Exists(cacheFilePath))
+ {
+ string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
+
+ if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
+ {
+ return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
+ }
+ }
+
+ return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
+ }
+ catch (Exception ex)
+ {
+ // If it fails for whatever reason, return the original image
+ _logger.LogError(ex, "Error encoding image");
+ return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
+ }
+ }
+
+ private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency)
+ {
+ var serverFormats = GetSupportedImageOutputFormats();
+
+ // Client doesn't care about format, so start with webp if supported
+ if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
+ {
+ return ImageFormat.Webp;
+ }
+
+ // If transparency is needed and webp isn't supported, than png is the only option
+ if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
+ {
+ return ImageFormat.Png;
+ }
+
+ foreach (var format in clientSupportedFormats)
+ {
+ if (serverFormats.Contains(format))
+ {
+ return format;
+ }
+ }
+
+ // We should never actually get here
+ return ImageFormat.Jpg;
+ }
+
+ private string GetMimeType(ImageFormat format, string path)
+ => format switch
+ {
+ ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"),
+ ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"),
+ ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"),
+ ImageFormat.Png => MimeTypes.GetMimeType("i.png"),
+ ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"),
+ _ => MimeTypes.GetMimeType(path)
+ };
+
+ /// <summary>
+ /// Gets the cache file path based on a set of parameters.
+ /// </summary>
+ private string GetCacheFilePath(
+ string originalPath,
+ int? width,
+ int? height,
+ int? maxWidth,
+ int? maxHeight,
+ int? fillWidth,
+ int? fillHeight,
+ int quality,
+ DateTime dateModified,
+ ImageFormat format,
+ bool addPlayedIndicator,
+ double percentPlayed,
+ int? unwatchedCount,
+ int? blur,
+ string backgroundColor,
+ string foregroundLayer)
+ {
+ var filename = new StringBuilder(256);
+ filename.Append(originalPath);
+
+ filename.Append(",quality=");
+ filename.Append(quality);
+
+ filename.Append(",datemodified=");
+ filename.Append(dateModified.Ticks);
+
+ filename.Append(",f=");
+ filename.Append(format);
+
+ if (width.HasValue)
+ {
+ filename.Append(",width=");
+ filename.Append(width.Value);
+ }
+
+ if (height.HasValue)
+ {
+ filename.Append(",height=");
+ filename.Append(height.Value);
+ }
+
+ if (maxWidth.HasValue)
+ {
+ filename.Append(",maxwidth=");
+ filename.Append(maxWidth.Value);
+ }
+
+ if (maxHeight.HasValue)
+ {
+ filename.Append(",maxheight=");
+ filename.Append(maxHeight.Value);
+ }
+
+ if (fillWidth.HasValue)
+ {
+ filename.Append(",fillwidth=");
+ filename.Append(fillWidth.Value);
+ }
+
+ if (fillHeight.HasValue)
+ {
+ filename.Append(",fillheight=");
+ filename.Append(fillHeight.Value);
+ }
+
+ if (addPlayedIndicator)
+ {
+ filename.Append(",pl=true");
+ }
+
+ if (percentPlayed > 0)
+ {
+ filename.Append(",p=");
+ filename.Append(percentPlayed);
+ }
+
+ if (unwatchedCount.HasValue)
+ {
+ filename.Append(",p=");
+ filename.Append(unwatchedCount.Value);
+ }
+
+ if (blur.HasValue)
+ {
+ filename.Append(",blur=");
+ filename.Append(blur.Value);
+ }
+
+ if (!string.IsNullOrEmpty(backgroundColor))
+ {
+ filename.Append(",b=");
+ filename.Append(backgroundColor);
+ }
+
+ if (!string.IsNullOrEmpty(foregroundLayer))
+ {
+ filename.Append(",fl=");
+ filename.Append(foregroundLayer);
+ }
+
+ filename.Append(",v=");
+ filename.Append(Version);
+
+ return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant());
+ }
+
+ /// <inheritdoc />
+ public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
+ {
+ int width = info.Width;
+ int height = info.Height;
+
+ if (height > 0 && width > 0)
+ {
+ return new ImageDimensions(width, height);
+ }
+
+ string path = info.Path;
+ _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
+
+ ImageDimensions size = GetImageDimensions(path);
+ info.Width = size.Width;
+ info.Height = size.Height;
+
+ return size;
+ }
+
+ /// <inheritdoc />
+ public ImageDimensions GetImageDimensions(string path)
+ => _imageEncoder.GetImageSize(path);
+
+ /// <inheritdoc />
+ public string GetImageBlurHash(string path)
+ {
+ var size = GetImageDimensions(path);
+ return GetImageBlurHash(path, size);
+ }
+
+ /// <inheritdoc />
+ public string GetImageBlurHash(string path, ImageDimensions imageDimensions)
+ {
+ if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0)
+ {
+ return string.Empty;
+ }
+
+ // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
+ // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width.
+ // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
+ float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height);
+ float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width;
+
+ int xComp = Math.Min((int)xCompF + 1, 9);
+ int yComp = Math.Min((int)yCompF + 1, 9);
+
+ return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
+ }
+
+ /// <inheritdoc />
+ public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
+ => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
+
+ /// <inheritdoc />
+ public string GetImageCacheTag(BaseItem item, ChapterInfo chapter)
+ {
+ return GetImageCacheTag(item, new ItemImageInfo
+ {
+ Path = chapter.ImagePath,
+ Type = ImageType.Chapter,
+ DateModified = chapter.ImageDateModified
+ });
+ }
+
+ /// <inheritdoc />
+ public string? GetImageCacheTag(User user)
+ {
+ if (user.ProfileImage is null)
+ {
+ return null;
+ }
+
+ return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
+ .ToString("N", CultureInfo.InvariantCulture);
+ }
+
+ private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
+ {
+ var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString();
+
+ // These are just jpg files renamed as tbn
+ if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
+ {
+ return Task.FromResult((originalImagePath, dateModified));
+ }
+
+ // TODO _mediaEncoder.ConvertImage is not implemented
+ // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
+ // {
+ // try
+ // {
+ // string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ //
+ // string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
+ // var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
+ //
+ // var file = _fileSystem.GetFileInfo(outputPath);
+ // if (!file.Exists)
+ // {
+ // await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
+ // dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
+ // }
+ // else
+ // {
+ // dateModified = file.LastWriteTimeUtc;
+ // }
+ //
+ // originalImagePath = outputPath;
+ // }
+ // catch (Exception ex)
+ // {
+ // _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
+ // }
+ // }
+
+ return Task.FromResult((originalImagePath, dateModified));
+ }
+
+ /// <summary>
+ /// Gets the cache path.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <param name="uniqueName">Name of the unique.</param>
+ /// <param name="fileExtension">The file extension.</param>
+ /// <returns>System.String.</returns>
+ /// <exception cref="ArgumentNullException">
+ /// path
+ /// or
+ /// uniqueName
+ /// or
+ /// fileExtension.
+ /// </exception>
+ public string GetCachePath(string path, string uniqueName, string fileExtension)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(path);
+ ArgumentException.ThrowIfNullOrEmpty(uniqueName);
+ ArgumentException.ThrowIfNullOrEmpty(fileExtension);
+
+ var filename = uniqueName.GetMD5() + fileExtension;
+
+ return GetCachePath(path, filename);
+ }
+
+ /// <summary>
+ /// Gets the cache path.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <param name="filename">The filename.</param>
+ /// <returns>System.String.</returns>
+ /// <exception cref="ArgumentNullException">
+ /// path
+ /// or
+ /// filename.
+ /// </exception>
+ public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
+ {
+ if (path.IsEmpty)
+ {
+ throw new ArgumentException("Path can't be empty.", nameof(path));
+ }
+
+ if (filename.IsEmpty)
+ {
+ throw new ArgumentException("Filename can't be empty.", nameof(filename));
+ }
+
+ var prefix = filename.Slice(0, 1);
+
+ return Path.Join(path, prefix, filename);
+ }
+
+ /// <inheritdoc />
+ public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+ {
+ _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
+
+ _imageEncoder.CreateImageCollage(options, libraryName);
+
+ _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
+ }
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (_imageEncoder is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+
+ _disposed = true;
+ }
+}
diff --git a/Emby.Drawing/Emby.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj
index 5bf226408..a5bc8eaa7 100644
--- a/Emby.Drawing/Emby.Drawing.csproj
+++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj
@@ -12,13 +12,13 @@
</PropertyGroup>
<ItemGroup>
- <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
- <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
- <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
+ <ProjectReference Include="..\..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
+ <ProjectReference Include="..\..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
+ <ProjectReference Include="..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
</ItemGroup>
<ItemGroup>
- <Compile Include="..\SharedVersion.cs" />
+ <Compile Include="..\..\SharedVersion.cs" />
</ItemGroup>
<!-- Code analysers-->
diff --git a/src/Jellyfin.Drawing/NullImageEncoder.cs b/src/Jellyfin.Drawing/NullImageEncoder.cs
new file mode 100644
index 000000000..171128bed
--- /dev/null
+++ b/src/Jellyfin.Drawing/NullImageEncoder.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Model.Drawing;
+
+namespace Jellyfin.Drawing;
+
+/// <summary>
+/// A fallback implementation of <see cref="IImageEncoder" />.
+/// </summary>
+public class NullImageEncoder : IImageEncoder
+{
+ /// <inheritdoc />
+ public IReadOnlyCollection<string> SupportedInputFormats
+ => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" };
+
+ /// <inheritdoc />
+ public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
+ => new HashSet<ImageFormat>() { ImageFormat.Jpg, ImageFormat.Png };
+
+ /// <inheritdoc />
+ public string Name => "Null Image Encoder";
+
+ /// <inheritdoc />
+ public bool SupportsImageCollageCreation => false;
+
+ /// <inheritdoc />
+ public bool SupportsImageEncoding => false;
+
+ /// <inheritdoc />
+ public ImageDimensions GetImageSize(string path)
+ => throw new NotImplementedException();
+
+ /// <inheritdoc />
+ public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
+ {
+ throw new NotImplementedException();
+ }
+
+ /// <inheritdoc />
+ public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+ {
+ throw new NotImplementedException();
+ }
+
+ /// <inheritdoc />
+ public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
+ {
+ throw new NotImplementedException();
+ }
+
+ /// <inheritdoc />
+ public string GetImageBlurHash(int xComp, int yComp, string path)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Emby.Drawing/Properties/AssemblyInfo.cs b/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs
index 281008e37..3851bf924 100644
--- a/Emby.Drawing/Properties/AssemblyInfo.cs
+++ b/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs
@@ -4,7 +4,7 @@ using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
-[assembly: AssemblyTitle("Emby.Drawing")]
+[assembly: AssemblyTitle("Jellyfin.Drawing")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("Jellyfin Project")]
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
index 243127438..9ace80bbd 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
@@ -26,7 +26,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
Path = "/media/sub.ass",
IsExternal = true
},
- new SubtitleEncoder.SubtitleInfo("/media/sub.ass", MediaProtocol.File, "ass", true));
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ass",
+ Protocol = MediaProtocol.File,
+ Format = "ass",
+ IsExternal = true
+ });
data.Add(
new MediaSourceInfo()
@@ -38,7 +44,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
Path = "/media/sub.ssa",
IsExternal = true
},
- new SubtitleEncoder.SubtitleInfo("/media/sub.ssa", MediaProtocol.File, "ssa", true));
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ssa",
+ Protocol = MediaProtocol.File,
+ Format = "ssa",
+ IsExternal = true
+ });
data.Add(
new MediaSourceInfo()
@@ -50,7 +62,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
Path = "/media/sub.srt",
IsExternal = true
},
- new SubtitleEncoder.SubtitleInfo("/media/sub.srt", MediaProtocol.File, "srt", true));
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.srt",
+ Protocol = MediaProtocol.File,
+ Format = "srt",
+ IsExternal = true
+ });
data.Add(
new MediaSourceInfo()
@@ -62,14 +80,20 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
Path = "/media/sub.ass",
IsExternal = true
},
- new SubtitleEncoder.SubtitleInfo("/media/sub.ass", MediaProtocol.File, "ass", true));
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ass",
+ Protocol = MediaProtocol.File,
+ Format = "ass",
+ IsExternal = true
+ });
return data;
}
[Theory]
[MemberData(nameof(GetReadableFile_Valid_TestData))]
- internal async Task GetReadableFile_Valid_Success(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleEncoder.SubtitleInfo subtitleInfo)
+ public async Task GetReadableFile_Valid_Success(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleEncoder.SubtitleInfo subtitleInfo)
{
var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
var subtitleEncoder = fixture.Create<SubtitleEncoder>();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaStreamSelectorTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaStreamSelectorTests.cs
index 538010f6c..07feae587 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaStreamSelectorTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaStreamSelectorTests.cs
@@ -51,4 +51,68 @@ public class MediaStreamSelectorTests
Assert.Equal(expectedIndex, MediaStreamSelector.GetDefaultAudioStreamIndex(streams, preferredLanguages, preferDefaultTrack));
}
+
+ public static TheoryData<MediaStream, int> GetStreamScore_MediaStream_TestData()
+ {
+ var data = new TheoryData<MediaStream, int>();
+
+ data.Add(new MediaStream(), 111111);
+ data.Add(
+ new MediaStream()
+ {
+ Language = "eng"
+ },
+ 10111111);
+ data.Add(
+ new MediaStream()
+ {
+ Language = "fre"
+ },
+ 10011111);
+ data.Add(
+ new MediaStream()
+ {
+ IsForced = true
+ },
+ 121111);
+ data.Add(
+ new MediaStream()
+ {
+ IsDefault = true
+ },
+ 112111);
+ data.Add(
+ new MediaStream()
+ {
+ SupportsExternalStream = true
+ },
+ 111211);
+ data.Add(
+ new MediaStream()
+ {
+ IsExternal = true
+ },
+ 111112);
+ data.Add(
+ new MediaStream()
+ {
+ Language = "eng",
+ IsForced = true,
+ IsDefault = true,
+ SupportsExternalStream = true,
+ IsExternal = true
+ },
+ 10122212);
+
+ return data;
+ }
+
+ [Theory]
+ [MemberData(nameof(GetStreamScore_MediaStream_TestData))]
+ public void GetStreamScore_MediaStream_CorrectScore(MediaStream stream, int expectedScore)
+ {
+ var languagePref = new[] { "eng", "fre" };
+
+ Assert.Equal(expectedScore, MediaStreamSelector.GetStreamScore(stream, languagePref));
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 3e7d6ed1d..16eb7a75c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -40,7 +40,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
await localizationManager.LoadAll();
var cultures = localizationManager.GetCultures().ToList();
- Assert.Equal(190, cultures.Count);
+ Assert.Equal(191, cultures.Count);
var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal));
Assert.NotNull(germany);