mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-06-03 05:34:16 -04:00
Merge remote-tracking branch 'upstream/master' into network-rewrite
This commit is contained in:
commit
656a0bff6f
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@ -20,18 +20,18 @@ 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:
|
||||
dotnet-version: '7.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # v2
|
||||
uses: github/codeql-action/init@a34ca99b4610d924e04c68db79e503e1f79f9f02 # v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # v2
|
||||
uses: github/codeql-action/autobuild@a34ca99b4610d924e04c68db79e503e1f79f9f02 # v2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # v2
|
||||
uses: github/codeql-action/analyze@a34ca99b4610d924e04c68db79e503e1f79f9f02 # v2
|
||||
|
4
.github/workflows/commands.yml
vendored
4
.github/workflows/commands.yml
vendored
@ -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
|
||||
|
24
.github/workflows/openapi.yml
vendored
24
.github/workflows/openapi.yml
vendored
@ -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,9 +39,17 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3
|
||||
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
- name: Checkout common ancestor
|
||||
run: |
|
||||
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/${{ github.head_ref }})
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3
|
||||
with:
|
||||
@ -49,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
|
||||
@ -68,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
|
||||
@ -95,7 +103,7 @@ jobs:
|
||||
body="${body//$'\r'/'%0D'}"
|
||||
echo ::set-output name=body::$body
|
||||
- name: Find difference comment
|
||||
uses: peter-evans/find-comment@f4499a714d59013c74a08789b48abe4b704364a0 # v2
|
||||
uses: peter-evans/find-comment@81e2da3af01c92f83cb927cf3ace0e085617c556 # v2
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
|
5
.github/workflows/repo-stale.yaml
vendored
5
.github/workflows/repo-stale.yaml
vendored
@ -5,13 +5,14 @@ on:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
issues: write
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # tag=v6
|
||||
- uses: actions/stale@6f05e4244c9a0b2ed3401882b05d701dd0a7289b # v7
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
days-before-stale: 120
|
||||
|
@ -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)
|
||||
|
@ -195,7 +195,7 @@ namespace Emby.Dlna.Didl
|
||||
{
|
||||
var sources = _mediaSourceManager.GetStaticMediaSources(video, true, _user);
|
||||
|
||||
streamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildVideoItem(new VideoOptions
|
||||
streamInfo = new StreamBuilder(_mediaEncoder, _logger).GetOptimalVideoStream(new MediaOptions
|
||||
{
|
||||
ItemId = video.Id,
|
||||
MediaSources = sources.ToArray(),
|
||||
@ -537,7 +537,7 @@ namespace Emby.Dlna.Didl
|
||||
{
|
||||
var sources = _mediaSourceManager.GetStaticMediaSources(audio, true, _user);
|
||||
|
||||
streamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildAudioItem(new AudioOptions
|
||||
streamInfo = new StreamBuilder(_mediaEncoder, _logger).GetOptimalAudioStream(new MediaOptions
|
||||
{
|
||||
ItemId = audio.Id,
|
||||
MediaSources = sources.ToArray(),
|
||||
|
@ -28,7 +28,7 @@
|
||||
|
||||
<!-- Code Analyzers-->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -585,7 +585,7 @@ namespace Emby.Dlna.PlayTo
|
||||
{
|
||||
return new PlaylistItem
|
||||
{
|
||||
StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildVideoItem(new VideoOptions
|
||||
StreamInfo = new StreamBuilder(_mediaEncoder, _logger).GetOptimalVideoStream(new MediaOptions
|
||||
{
|
||||
ItemId = item.Id,
|
||||
MediaSources = mediaSources,
|
||||
@ -605,7 +605,7 @@ namespace Emby.Dlna.PlayTo
|
||||
{
|
||||
return new PlaylistItem
|
||||
{
|
||||
StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildAudioItem(new AudioOptions
|
||||
StreamInfo = new StreamBuilder(_mediaEncoder, _logger).GetOptimalAudioStream(new MediaOptions
|
||||
{
|
||||
ItemId = item.Id,
|
||||
MediaSources = mediaSources,
|
||||
|
@ -92,6 +92,12 @@ namespace Emby.Dlna.Profiles
|
||||
Method = SubtitleDeliveryMethod.External,
|
||||
},
|
||||
|
||||
new SubtitleProfile
|
||||
{
|
||||
Format = "sup",
|
||||
Method = SubtitleDeliveryMethod.External
|
||||
},
|
||||
|
||||
new SubtitleProfile
|
||||
{
|
||||
Format = "srt",
|
||||
@ -140,6 +146,12 @@ namespace Emby.Dlna.Profiles
|
||||
Method = SubtitleDeliveryMethod.Embed
|
||||
},
|
||||
|
||||
new SubtitleProfile
|
||||
{
|
||||
Format = "sup",
|
||||
Method = SubtitleDeliveryMethod.Embed
|
||||
},
|
||||
|
||||
new SubtitleProfile
|
||||
{
|
||||
Format = "subrip",
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -153,7 +153,7 @@ namespace Emby.Naming.Common
|
||||
|
||||
CleanStrings = new[]
|
||||
{
|
||||
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
|
||||
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
|
||||
@"^(?<cleaned>.+?)(\[.*\])",
|
||||
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
|
||||
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
|
||||
@ -169,6 +169,7 @@ namespace Emby.Naming.Common
|
||||
".srt",
|
||||
".ssa",
|
||||
".sub",
|
||||
".sup",
|
||||
".vtt",
|
||||
};
|
||||
|
||||
|
@ -47,7 +47,7 @@
|
||||
|
||||
<!-- Code Analyzers-->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Code analyzers-->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Code Analyzers-->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -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;
|
||||
@ -193,11 +193,6 @@ namespace Emby.Server.Implementations
|
||||
/// </summary>
|
||||
private string PublishedServerUrl => _startupConfig[AddressOverrideKey];
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this instance can self restart.
|
||||
/// </summary>
|
||||
public bool CanSelfRestart => _startupOptions.RestartPath is not null;
|
||||
|
||||
public bool CoreStartupHasCompleted { get; private set; }
|
||||
|
||||
public virtual bool CanLaunchWebBrowser
|
||||
@ -654,7 +649,7 @@ namespace Emby.Server.Implementations
|
||||
/// <returns>A task representing the service initialization operation.</returns>
|
||||
public async Task InitializeServices()
|
||||
{
|
||||
var jellyfinDb = await Resolve<IDbContextFactory<JellyfinDb>>().CreateDbContextAsync().ConfigureAwait(false);
|
||||
var jellyfinDb = await Resolve<IDbContextFactory<JellyfinDbContext>>().CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (jellyfinDb.ConfigureAwait(false))
|
||||
{
|
||||
if ((await jellyfinDb.Database.GetPendingMigrationsAsync().ConfigureAwait(false)).Any())
|
||||
@ -935,17 +930,13 @@ namespace Emby.Server.Implementations
|
||||
/// </summary>
|
||||
public void Restart()
|
||||
{
|
||||
if (!CanSelfRestart)
|
||||
{
|
||||
throw new PlatformNotSupportedException("The server is unable to self-restart. Please restart manually.");
|
||||
}
|
||||
|
||||
if (IsShuttingDown)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsShuttingDown = true;
|
||||
_pluginManager.UnloadAssemblies();
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
@ -1047,7 +1038,6 @@ namespace Emby.Server.Implementations
|
||||
CachePath = ApplicationPaths.CachePath,
|
||||
OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(),
|
||||
OperatingSystemDisplayName = MediaBrowser.Common.System.OperatingSystem.Name,
|
||||
CanSelfRestart = CanSelfRestart,
|
||||
CanLaunchWebBrowser = CanLaunchWebBrowser,
|
||||
TranscodingTempPath = ConfigurationManager.GetTranscodePath(),
|
||||
ServerName = FriendlyName,
|
||||
|
@ -60,11 +60,22 @@ namespace Emby.Server.Implementations.Data
|
||||
/// <value>The cache size or null.</value>
|
||||
protected virtual int? CacheSize => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the locking mode. <see href="https://www.sqlite.org/pragma.html#pragma_locking_mode" />.
|
||||
/// </summary>
|
||||
protected virtual string LockingMode => "EXCLUSIVE";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the journal mode. <see href="https://www.sqlite.org/pragma.html#pragma_journal_mode" />.
|
||||
/// </summary>
|
||||
/// <value>The journal mode.</value>
|
||||
protected virtual string JournalMode => "TRUNCATE";
|
||||
protected virtual string JournalMode => "WAL";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the journal size limit. <see href="https://www.sqlite.org/pragma.html#pragma_journal_size_limit" />.
|
||||
/// </summary>
|
||||
/// <value>The journal size limit.</value>
|
||||
protected virtual int? JournalSizeLimit => 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the page size.
|
||||
@ -84,7 +95,7 @@ namespace Emby.Server.Implementations.Data
|
||||
/// </summary>
|
||||
/// <value>The synchronous mode or null.</value>
|
||||
/// <see cref="SynchronousMode"/>
|
||||
protected virtual SynchronousMode? Synchronous => null;
|
||||
protected virtual SynchronousMode? Synchronous => SynchronousMode.Normal;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the write lock.
|
||||
@ -116,11 +127,21 @@ namespace Emby.Server.Implementations.Data
|
||||
WriteConnection.Execute("PRAGMA cache_size=" + CacheSize.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(LockingMode))
|
||||
{
|
||||
WriteConnection.Execute("PRAGMA locking_mode=" + LockingMode);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(JournalMode))
|
||||
{
|
||||
WriteConnection.Execute("PRAGMA journal_mode=" + JournalMode);
|
||||
}
|
||||
|
||||
if (JournalSizeLimit.HasValue)
|
||||
{
|
||||
WriteConnection.Execute("PRAGMA journal_size_limit=" + (int)JournalSizeLimit.Value);
|
||||
}
|
||||
|
||||
if (Synchronous.HasValue)
|
||||
{
|
||||
WriteConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value);
|
||||
|
@ -359,8 +359,6 @@ namespace Emby.Server.Implementations.Data
|
||||
|
||||
string[] queries =
|
||||
{
|
||||
"PRAGMA locking_mode=EXCLUSIVE",
|
||||
|
||||
"create table if not exists TypedBaseItems (guid GUID primary key NOT NULL, type TEXT NOT NULL, data BLOB NULL, ParentId GUID NULL, Path TEXT NULL)",
|
||||
|
||||
"create table if not exists AncestorIds (ItemId GUID NOT NULL, AncestorId GUID NOT NULL, AncestorIdText TEXT NOT NULL, PRIMARY KEY (ItemId, AncestorId))",
|
||||
@ -385,39 +383,6 @@ namespace Emby.Server.Implementations.Data
|
||||
|
||||
string[] postQueries =
|
||||
{
|
||||
// obsolete
|
||||
"drop index if exists idx_TypedBaseItems",
|
||||
"drop index if exists idx_mediastreams",
|
||||
"drop index if exists idx_mediastreams1",
|
||||
"drop index if exists idx_" + ChaptersTableName,
|
||||
"drop index if exists idx_UserDataKeys1",
|
||||
"drop index if exists idx_UserDataKeys2",
|
||||
"drop index if exists idx_TypeTopParentId3",
|
||||
"drop index if exists idx_TypeTopParentId2",
|
||||
"drop index if exists idx_TypeTopParentId4",
|
||||
"drop index if exists idx_Type",
|
||||
"drop index if exists idx_TypeTopParentId",
|
||||
"drop index if exists idx_GuidType",
|
||||
"drop index if exists idx_TopParentId",
|
||||
"drop index if exists idx_TypeTopParentId6",
|
||||
"drop index if exists idx_ItemValues2",
|
||||
"drop index if exists Idx_ProviderIds",
|
||||
"drop index if exists idx_ItemValues3",
|
||||
"drop index if exists idx_ItemValues4",
|
||||
"drop index if exists idx_ItemValues5",
|
||||
"drop index if exists idx_UserDataKeys3",
|
||||
"drop table if exists UserDataKeys",
|
||||
"drop table if exists ProviderIds",
|
||||
"drop index if exists Idx_ProviderIds1",
|
||||
"drop table if exists Images",
|
||||
"drop index if exists idx_Images",
|
||||
"drop index if exists idx_TypeSeriesPresentationUniqueKey",
|
||||
"drop index if exists idx_SeriesPresentationUniqueKey",
|
||||
"drop index if exists idx_TypeSeriesPresentationUniqueKey2",
|
||||
"drop index if exists idx_AncestorIds3",
|
||||
"drop index if exists idx_AncestorIds4",
|
||||
"drop index if exists idx_AncestorIds2",
|
||||
|
||||
"create index if not exists idx_PathTypedBaseItems on TypedBaseItems(Path)",
|
||||
"create index if not exists idx_ParentIdTypedBaseItems on TypedBaseItems(ParentId)",
|
||||
|
||||
@ -458,6 +423,9 @@ namespace Emby.Server.Implementations.Data
|
||||
|
||||
// Used to update inherited tags
|
||||
"create index if not exists idx_ItemValues8 on ItemValues(Type, ItemId, Value)",
|
||||
|
||||
"CREATE INDEX IF NOT EXISTS idx_TypedBaseItemsUserDataKeyType ON TypedBaseItems(UserDataKey, Type)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_PeopleNameListOrder ON People(Name, ListOrder)"
|
||||
};
|
||||
|
||||
using (var connection = GetConnection())
|
||||
@ -2401,13 +2369,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 +2493,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)
|
||||
|
@ -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" />
|
||||
@ -54,7 +54,7 @@
|
||||
|
||||
<!-- Code Analyzers-->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -20,16 +20,6 @@ namespace Emby.Server.Implementations
|
||||
/// </summary>
|
||||
string? PackageName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of the --restartpath command line option.
|
||||
/// </summary>
|
||||
string? RestartPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of the --restartargs command line option.
|
||||
/// </summary>
|
||||
string? RestartArgs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of the --published-server-url command line option.
|
||||
/// </summary>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1814,6 +1814,10 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
|
||||
program.AddGenre("News");
|
||||
}
|
||||
|
||||
var config = GetConfiguration();
|
||||
|
||||
if (config.SaveRecordingNFO)
|
||||
{
|
||||
if (timer.IsProgramSeries)
|
||||
{
|
||||
await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
|
||||
@ -1827,9 +1831,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
|
||||
{
|
||||
await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.SaveRecordingImages)
|
||||
{
|
||||
await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving nfo");
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -3,20 +3,20 @@
|
||||
"AppDeviceValues": "تطبيق: {0}, جهاز: {1}",
|
||||
"Application": "تطبيق",
|
||||
"Artists": "الفنانين",
|
||||
"AuthenticationSucceededWithUserName": "تمت مصادقة {0} بنجاح",
|
||||
"AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}",
|
||||
"Books": "الكتب",
|
||||
"CameraImageUploadedFrom": "صورة كاميرا جديدة تم رفعها من {0}",
|
||||
"CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}",
|
||||
"Channels": "القنوات",
|
||||
"ChapterNameValue": "الفصل {0}",
|
||||
"Collections": "التجميعات",
|
||||
"DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
|
||||
"DeviceOnlineWithName": "{0} متصل",
|
||||
"FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فشلت من {0}",
|
||||
"Favorites": "مفضلات",
|
||||
"Favorites": "المفضلة",
|
||||
"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": "يجب إعادة تشغيل الخادم",
|
||||
@ -91,13 +91,13 @@
|
||||
"UserStoppedPlayingItemWithValues": "قام {0} بإيقاف تشغيل {1} على {2}",
|
||||
"ValueHasBeenAddedToLibrary": "تمت اضافت {0} إلى مكتبة الوسائط",
|
||||
"ValueSpecialEpisodeName": "حلقه خاصه - {0}",
|
||||
"VersionNumber": "النسخة {0}",
|
||||
"VersionNumber": "الإصدار {0}",
|
||||
"TaskCleanCacheDescription": "يحذف الملفات المؤقتة التي لم يعد النظام بحاجة إليها.",
|
||||
"TaskCleanCache": "احذف ما بمجلد الملفات المؤقتة",
|
||||
"TasksChannelsCategory": "قنوات الإنترنت",
|
||||
"TasksLibraryCategory": "مكتبة",
|
||||
"TasksMaintenanceCategory": "صيانة",
|
||||
"TaskRefreshLibraryDescription": "يفحص مكتبة الوسائط الخاصة بك باحثا عن ملفات جديدة، ومن ثم يتحدث البيانات الوصفية.",
|
||||
"TaskRefreshLibraryDescription": "يفحص مكتبة الوسائط الخاصة بك باحثا عن ملفات جديدة، ومن ثم يُحدث البيانات الوصفية.",
|
||||
"TaskRefreshLibrary": "افحص مكتبة الوسائط",
|
||||
"TaskRefreshChapterImagesDescription": "يُنشئ صور مصغرة لمقاطع الفيديو التي تحتوي على فصول.",
|
||||
"TaskRefreshChapterImages": "استخراج صور الفصل",
|
||||
@ -123,5 +123,6 @@
|
||||
"TaskOptimizeDatabase": "تحسين قاعدة البيانات",
|
||||
"TaskKeyframeExtractorDescription": "يستخرج الإطارات الرئيسية من ملفات الفيديو لكي ينشئ قوائم تشغيل بث HTTP المباشر. قد تستمر هذه العملية لوقت طويل.",
|
||||
"TaskKeyframeExtractor": "مستخرج الإطار الرئيسي",
|
||||
"External": "خارجي"
|
||||
"External": "خارجي",
|
||||
"HearingImpaired": "ضعاف السمع"
|
||||
}
|
||||
|
@ -40,7 +40,7 @@
|
||||
"Movies": "Pel·lícules",
|
||||
"Music": "Música",
|
||||
"MusicVideos": "Vídeos Musicals",
|
||||
"NameInstallFailed": "Instal·lació de {0} fallida",
|
||||
"NameInstallFailed": "{0} instal·lació fallida",
|
||||
"NameSeasonNumber": "Temporada {0}",
|
||||
"NameSeasonUnknown": "Temporada Desconeguda",
|
||||
"NewVersionIsAvailable": "Una nova versió del Servidor Jellyfin està disponible per descarregar.",
|
||||
@ -118,7 +118,7 @@
|
||||
"TaskCleanActivityLog": "Buidar Registre d'Activitat",
|
||||
"Undefined": "Indefinit",
|
||||
"Forced": "Forçat",
|
||||
"Default": "Defecte",
|
||||
"Default": "Per defecte",
|
||||
"TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la biblioteca o fer altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.",
|
||||
"TaskOptimizeDatabase": "Optimitzar la base de dades",
|
||||
"TaskKeyframeExtractorDescription": "Extreu fotogrames clau dels fitxers de vídeo per crear llistes de reproducció HLS més precises. Aquesta tasca pot durar molt de temps.",
|
||||
|
@ -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": "Προεπιλογή",
|
||||
|
@ -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",
|
||||
|
@ -122,5 +122,6 @@
|
||||
"TaskOptimizeDatabase": "データベースの最適化",
|
||||
"TaskKeyframeExtractorDescription": "より正確なHLSプレイリストを作成するため、動画ファイルからキーフレームを抽出する。この処理には時間がかかる場合があります。",
|
||||
"TaskKeyframeExtractor": "キーフレーム抽出",
|
||||
"External": "外部"
|
||||
"External": "外部",
|
||||
"HearingImpaired": "聴覚障害の方"
|
||||
}
|
||||
|
@ -108,5 +108,20 @@
|
||||
"UserPasswordChangedWithName": "მომხმარებლისთვის {0} პაროლი შეცვლილია",
|
||||
"UserPolicyUpdatedWithName": "{0}-ის მომხმარებლის პოლიტიკა განახლდა",
|
||||
"UserStoppedPlayingItemWithValues": "{0}-მა დაამთავრა {1}-ის დაკვრა {2}-ზე",
|
||||
"TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა."
|
||||
"TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა.",
|
||||
"TaskKeyframeExtractorDescription": "უფრო ზუსტი HLS დასაკრავი სიებისითვის ვიდეოდან საკვანძო გადრების ამოღება. შეიძლება საკმაო დრო დასჭირდეს.",
|
||||
"NewVersionIsAvailable": "გადმოსაწერად ხელმისაწვდომია Jellyfin -ის ახალი ვერსია.",
|
||||
"CameraImageUploadedFrom": "ახალი კამერის გამოსახულება ატვირთულია {0}-დან",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin სერვერი იტვირთება. მოგვიანებით სცადეთ.",
|
||||
"SubtitleDownloadFailureFromForItem": "{0}-დან {1}-სთვის სუბტიტრების გადმოწერის შეცდომა",
|
||||
"ValueHasBeenAddedToLibrary": "{0} დაემატა თქვენს მედიის ბიბლიოთეკას",
|
||||
"TaskCleanActivityLogDescription": "მითითებულ ასაკზე ძველი ჟურნალის ჩანაწერების წაშლა.",
|
||||
"TaskCleanCacheDescription": "სისტემისთვის არასაჭირო ქეშის ფაილების წაშლა.",
|
||||
"TaskRefreshLibraryDescription": "თქვენი მედია ბიბლიოთეკაში ახალი ფაილების ძებნა და მეტამონაცემების განახლება.",
|
||||
"TaskCleanLogsDescription": "{0} დღეზე ძველი ჟურნალის ფაილების წაშლა.",
|
||||
"TaskRefreshPeopleDescription": "თქვენს მედიის ბიბლიოთეკაში მსახიობების და რეჟისორების მეტამონაცემების განახლება.",
|
||||
"TaskUpdatePluginsDescription": "ავტომატურად განახლებადად მონიშნული დამატებების განახლებების გადმოწერა და დაყენება.",
|
||||
"TaskCleanTranscodeDescription": "ერთ დღეზე უფრო ძველი ტრანსკოდირების ფაილების წაშლა.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "მეტამონაცემებზე დაყრდნობით ინტერნეტში ნაკლული სუბტიტრების ძებნა.",
|
||||
"TaskOptimizeDatabaseDescription": "ბაზს შეკუშვა და ადგილის გათავისუფლება. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს."
|
||||
}
|
||||
|
@ -123,5 +123,6 @@
|
||||
"TaskOptimizeDatabase": "데이터베이스 최적화",
|
||||
"TaskKeyframeExtractorDescription": "비디오 파일에서 키프레임을 추출하여 더 정확한 HLS 재생 목록을 만듭니다. 이 작업은 오랫동안 진행될 수 있습니다.",
|
||||
"TaskKeyframeExtractor": "키프레임 추출",
|
||||
"External": "외부"
|
||||
"External": "외부",
|
||||
"HearingImpaired": "청각 장애"
|
||||
}
|
||||
|
@ -123,5 +123,6 @@
|
||||
"TaskOptimizeDatabaseDescription": "Kompaktuje bazę danych i obcina wolne miejsce. Uruchomienie tego zadania po przeskanowaniu biblioteki lub dokonaniu innych zmian, które pociągają za sobą modyfikacje bazy danych, może poprawić wydajność.",
|
||||
"External": "Zewnętrzny",
|
||||
"TaskKeyframeExtractorDescription": "Wyodrębnia klatki kluczowe z plików wideo w celu utworzenia bardziej precyzyjnych list odtwarzania HLS. To zadanie może trwać przez długi czas.",
|
||||
"TaskKeyframeExtractor": "Ekstraktor klatek kluczowych"
|
||||
"TaskKeyframeExtractor": "Ekstraktor klatek kluczowych",
|
||||
"HearingImpaired": "Niedosłyszący"
|
||||
}
|
||||
|
@ -13,5 +13,11 @@
|
||||
"DeviceOfflineWithName": "{0} abandoned ship",
|
||||
"AppDeviceValues": "Captain: {0}, Ship: {1}",
|
||||
"CameraImageUploadedFrom": "Yer looking glass has glimpsed another painting from {0}",
|
||||
"Collections": "Barrels"
|
||||
"Collections": "Barrels",
|
||||
"ItemAddedWithName": "{0} is now with yer treasure",
|
||||
"Default": "Normal-like",
|
||||
"FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}",
|
||||
"Favorites": "Finest Loot",
|
||||
"ItemRemovedWithName": "{0} was taken from yer treasure",
|
||||
"LabelIpAddressValue": "Ship's coordinates: {0}"
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"Albums": "Альбомы",
|
||||
"AppDeviceValues": "Приложение.: {0}, Устройство.: {1}",
|
||||
"AppDeviceValues": "Приложение: {0}, Устройство: {1}",
|
||||
"Application": "Приложение",
|
||||
"Artists": "Исполнители",
|
||||
"AuthenticationSucceededWithUserName": "{0} - авторизация успешна",
|
||||
@ -50,7 +50,7 @@
|
||||
"NotificationOptionAudioPlaybackStopped": "Воспроизведение аудио остановлено",
|
||||
"NotificationOptionCameraImageUploaded": "Изображения с камеры загружены",
|
||||
"NotificationOptionInstallationFailed": "Сбой установки",
|
||||
"NotificationOptionNewLibraryContent": "Новое содержание добавлено",
|
||||
"NotificationOptionNewLibraryContent": "Новое содержимое добавлено",
|
||||
"NotificationOptionPluginError": "Сбой плагина",
|
||||
"NotificationOptionPluginInstalled": "Плагин установлен",
|
||||
"NotificationOptionPluginUninstalled": "Плагин удалён",
|
||||
|
@ -122,5 +122,6 @@
|
||||
"TaskOptimizeDatabaseDescription": "Сажима базу података и скраћује слободан простор. Покретање овог задатка након скенирања библиотеке или других промена које подразумевају измене базе података које могу побољшати перформансе.",
|
||||
"External": "Спољно",
|
||||
"TaskKeyframeExtractorDescription": "Екстрактује кљулне сличице из видео датотека да би креирао више преицзну HLS плеј-листу. Овај задатак може да потраје дуже време.",
|
||||
"TaskKeyframeExtractor": "Екстрактор кључних сличица"
|
||||
"TaskKeyframeExtractor": "Екстрактор кључних сличица",
|
||||
"HearingImpaired": "ослабљен слух"
|
||||
}
|
||||
|
@ -120,5 +120,6 @@
|
||||
"Forced": "บังคับใช้",
|
||||
"TaskOptimizeDatabase": "ปรับปรุงประสิทธิภาพฐานข้อมูล",
|
||||
"TaskOptimizeDatabaseDescription": "ลดขนาดการจัดเก็บฐานข้อมูล ใช้งานคำสั่งนี้หลังจากสแกนไลบรารีหรือหลังจากการเปลี่ยนแปลงฐานข้อมูล อาจจะทำให้ระบบทำงานเร็วขึ้น",
|
||||
"External": "ภายนอก"
|
||||
"External": "ภายนอก",
|
||||
"HearingImpaired": "บกพร่องทางการได้ยิน"
|
||||
}
|
||||
|
@ -9,15 +9,15 @@
|
||||
"Channels": "頻道",
|
||||
"ChapterNameValue": "章節 {0}",
|
||||
"Collections": "合輯",
|
||||
"DeviceOfflineWithName": "{0} 已經斷開連結",
|
||||
"DeviceOfflineWithName": "{0} 已經斷開連接",
|
||||
"DeviceOnlineWithName": "{0} 已經連接",
|
||||
"FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗",
|
||||
"FailedLoginAttemptWithUserName": "{0} 登入失敗",
|
||||
"Favorites": "我的最愛",
|
||||
"Folders": "資料夾",
|
||||
"Genres": "風格",
|
||||
"HeaderAlbumArtists": "專輯藝人",
|
||||
"HeaderContinueWatching": "繼續觀看",
|
||||
"HeaderFavoriteAlbums": "最愛專輯",
|
||||
"HeaderFavoriteAlbums": "最愛的專輯",
|
||||
"HeaderFavoriteArtists": "最愛的藝人",
|
||||
"HeaderFavoriteEpisodes": "最愛的劇集",
|
||||
"HeaderFavoriteShows": "最愛的節目",
|
||||
@ -44,10 +44,10 @@
|
||||
"NameSeasonNumber": "第 {0} 季",
|
||||
"NameSeasonUnknown": "未知季數",
|
||||
"NewVersionIsAvailable": "新版本的 Jellyfin 伺服器可供下載。",
|
||||
"NotificationOptionApplicationUpdateAvailable": "有可用的應用程式更新",
|
||||
"NotificationOptionApplicationUpdateAvailable": "有可用的更新",
|
||||
"NotificationOptionApplicationUpdateInstalled": "應用程式已更新",
|
||||
"NotificationOptionAudioPlayback": "開始播放音頻",
|
||||
"NotificationOptionAudioPlaybackStopped": "已停止播放音頻",
|
||||
"NotificationOptionAudioPlayback": "開始播放音訊",
|
||||
"NotificationOptionAudioPlaybackStopped": "已停止播放音訊",
|
||||
"NotificationOptionCameraImageUploaded": "相片已上傳",
|
||||
"NotificationOptionInstallationFailed": "安裝失敗",
|
||||
"NotificationOptionNewLibraryContent": "已添加新内容",
|
||||
|
@ -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
|
||||
|
33
Emby.Server.Implementations/Plugins/PluginLoadContext.cs
Normal file
33
Emby.Server.Implementations/Plugins/PluginLoadContext.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace Emby.Server.Implementations.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// A custom <see cref="AssemblyLoadContext"/> for loading Jellyfin plugins.
|
||||
/// </summary>
|
||||
public class PluginLoadContext : AssemblyLoadContext
|
||||
{
|
||||
private readonly AssemblyDependencyResolver _resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginLoadContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="path">The path of the plugin assembly.</param>
|
||||
public PluginLoadContext(string path) : base(true)
|
||||
{
|
||||
_resolver = new AssemblyDependencyResolver(path);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||
if (assemblyPath is not null)
|
||||
{
|
||||
return LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
@ -30,6 +31,7 @@ namespace Emby.Server.Implementations.Plugins
|
||||
{
|
||||
private readonly string _pluginsPath;
|
||||
private readonly Version _appVersion;
|
||||
private readonly List<AssemblyLoadContext> _assemblyLoadContexts;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly ILogger<PluginManager> _logger;
|
||||
private readonly IApplicationHost _appHost;
|
||||
@ -76,6 +78,8 @@ namespace Emby.Server.Implementations.Plugins
|
||||
_appHost = appHost;
|
||||
_minimumVersion = new Version(0, 0, 0, 1);
|
||||
_plugins = Directory.Exists(_pluginsPath) ? DiscoverPlugins().ToList() : new List<LocalPlugin>();
|
||||
|
||||
_assemblyLoadContexts = new List<AssemblyLoadContext>();
|
||||
}
|
||||
|
||||
private IHttpClientFactory HttpClientFactory
|
||||
@ -124,7 +128,10 @@ namespace Emby.Server.Implementations.Plugins
|
||||
Assembly assembly;
|
||||
try
|
||||
{
|
||||
assembly = Assembly.LoadFrom(file);
|
||||
var assemblyLoadContext = new PluginLoadContext(file);
|
||||
_assemblyLoadContexts.Add(assemblyLoadContext);
|
||||
|
||||
assembly = assemblyLoadContext.LoadFromAssemblyPath(file);
|
||||
|
||||
// Load all required types to verify that the plugin will load
|
||||
assembly.GetTypes();
|
||||
@ -156,6 +163,15 @@ namespace Emby.Server.Implementations.Plugins
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UnloadAssemblies()
|
||||
{
|
||||
foreach (var assemblyLoadContext in _assemblyLoadContexts)
|
||||
{
|
||||
assemblyLoadContext.Unload();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates all the plugin instances.
|
||||
/// </summary>
|
||||
|
@ -17,7 +17,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
|
||||
{
|
||||
private readonly ILogger<OptimizeDatabaseTask> _logger;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IDbContextFactory<JellyfinDb> _provider;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _provider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
|
||||
@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
|
||||
public OptimizeDatabaseTask(
|
||||
ILogger<OptimizeDatabaseTask> logger,
|
||||
ILocalizationManager localization,
|
||||
IDbContextFactory<JellyfinDb> provider)
|
||||
IDbContextFactory<JellyfinDbContext> provider)
|
||||
{
|
||||
_logger = logger;
|
||||
_localization = localization;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -17,24 +17,6 @@ namespace Jellyfin.Api
|
||||
JsonDefaults.PascalCaseMediaType)]
|
||||
public class BaseJellyfinApiController : ControllerBase
|
||||
{
|
||||
/// <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>(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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -1704,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";
|
||||
}
|
||||
@ -1731,7 +1733,12 @@ namespace Jellyfin.Api.Controllers
|
||||
|
||||
var channels = state.OutputAudioChannels;
|
||||
|
||||
if (channels.HasValue)
|
||||
if (channels.HasValue
|
||||
&& (channels.Value != 2
|
||||
|| (state.AudioStream is not null
|
||||
&& state.AudioStream.Channels.HasValue
|
||||
&& state.AudioStream.Channels.Value > 5
|
||||
&& _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None)))
|
||||
{
|
||||
args += " -ac " + channels.Value;
|
||||
}
|
||||
|
@ -28,7 +28,6 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Api.Controllers
|
||||
@ -106,8 +105,9 @@ namespace Jellyfin.Api.Controllers
|
||||
}
|
||||
|
||||
var user = _userManager.GetUserById(userId);
|
||||
await using var memoryStream = await GetMemoryStream(Request.Body).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();
|
||||
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
|
||||
@ -125,6 +125,7 @@ namespace Jellyfin.Api.Controllers
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the user image.
|
||||
@ -153,8 +154,9 @@ namespace Jellyfin.Api.Controllers
|
||||
}
|
||||
|
||||
var user = _userManager.GetUserById(userId);
|
||||
await using var memoryStream = await GetMemoryStream(Request.Body).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();
|
||||
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
|
||||
@ -172,6 +174,7 @@ namespace Jellyfin.Api.Controllers
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete the user's image.
|
||||
@ -341,8 +344,9 @@ namespace Jellyfin.Api.Controllers
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await using var memoryStream = await GetMemoryStream(Request.Body).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);
|
||||
@ -350,6 +354,7 @@ namespace Jellyfin.Api.Controllers
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set item image.
|
||||
@ -377,8 +382,9 @@ namespace Jellyfin.Api.Controllers
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await using var memoryStream = await GetMemoryStream(Request.Body).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);
|
||||
@ -386,6 +392,7 @@ namespace Jellyfin.Api.Controllers
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the index for an item image.
|
||||
@ -1788,8 +1795,9 @@ 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;
|
||||
|
||||
if (!mimeType.HasValue)
|
||||
@ -1808,13 +1816,15 @@ namespace Jellyfin.Api.Controllers
|
||||
brandingOptions.SplashscreenLocation = filePath;
|
||||
_serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
|
||||
|
||||
await using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous))
|
||||
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);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a custom splashscreen.
|
||||
@ -2027,13 +2037,8 @@ namespace Jellyfin.Api.Controllers
|
||||
}
|
||||
|
||||
var acceptParam = Request.Query[HeaderNames.Accept];
|
||||
if (StringValues.IsNullOrEmpty(acceptParam))
|
||||
{
|
||||
return Array.Empty<ImageFormat>();
|
||||
}
|
||||
|
||||
// Can't be null, checked above
|
||||
var supportsWebP = SupportsFormat(supportedFormats, acceptParam!, ImageFormat.Webp, false);
|
||||
var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false);
|
||||
|
||||
if (!supportsWebP)
|
||||
{
|
||||
@ -2055,8 +2060,7 @@ namespace Jellyfin.Api.Controllers
|
||||
formats.Add(ImageFormat.Jpg);
|
||||
formats.Add(ImageFormat.Png);
|
||||
|
||||
// Can't be null, checked above
|
||||
if (SupportsFormat(supportedFormats, acceptParam!, ImageFormat.Gif, true))
|
||||
if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true))
|
||||
{
|
||||
formats.Add(ImageFormat.Gif);
|
||||
}
|
||||
@ -2064,7 +2068,7 @@ namespace Jellyfin.Api.Controllers
|
||||
return formats.ToArray();
|
||||
}
|
||||
|
||||
private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string acceptParam, ImageFormat format, bool acceptAll)
|
||||
private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll)
|
||||
{
|
||||
if (requestAcceptTypes.Contains(format.GetMimeType()))
|
||||
{
|
||||
|
@ -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);
|
||||
|
@ -1,12 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Models.NotificationDtos;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Notifications;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Notifications;
|
||||
@ -23,41 +16,14 @@ namespace Jellyfin.Api.Controllers
|
||||
public class NotificationsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly INotificationManager _notificationManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NotificationsController" /> class.
|
||||
/// </summary>
|
||||
/// <param name="notificationManager">The notification manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
public NotificationsController(INotificationManager notificationManager, IUserManager userManager)
|
||||
public NotificationsController(INotificationManager notificationManager)
|
||||
{
|
||||
_notificationManager = notificationManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a user's notifications.
|
||||
/// </summary>
|
||||
/// <response code="200">Notifications returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing a list of notifications.</returns>
|
||||
[HttpGet("{userId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<NotificationResultDto> GetNotifications()
|
||||
{
|
||||
return new NotificationResultDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a user's notification summary.
|
||||
/// </summary>
|
||||
/// <response code="200">Summary of user's notifications returned.</response>
|
||||
/// <returns>An <cref see="OkResult"/> containing a summary of the users notifications.</returns>
|
||||
[HttpGet("{userId}/Summary")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<NotificationsSummaryDto> GetNotificationsSummary()
|
||||
{
|
||||
return new NotificationsSummaryDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -83,56 +49,5 @@ namespace Jellyfin.Api.Controllers
|
||||
{
|
||||
return _notificationManager.GetNotificationServices();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification to all admins.
|
||||
/// </summary>
|
||||
/// <param name="notificationDto">The notification request.</param>
|
||||
/// <response code="204">Notification sent.</response>
|
||||
/// <returns>A <cref see="NoContentResult"/>.</returns>
|
||||
[HttpPost("Admin")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult CreateAdminNotification([FromBody, Required] AdminNotificationDto notificationDto)
|
||||
{
|
||||
var notification = new NotificationRequest
|
||||
{
|
||||
Name = notificationDto.Name,
|
||||
Description = notificationDto.Description,
|
||||
Url = notificationDto.Url,
|
||||
Level = notificationDto.NotificationLevel ?? NotificationLevel.Normal,
|
||||
UserIds = _userManager.Users
|
||||
.Where(user => user.HasPermission(PermissionKind.IsAdministrator))
|
||||
.Select(user => user.Id)
|
||||
.ToArray(),
|
||||
Date = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_notificationManager.SendNotification(notification, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets notifications as read.
|
||||
/// </summary>
|
||||
/// <response code="204">Notifications set as read.</response>
|
||||
/// <returns>A <cref see="NoContentResult"/>.</returns>
|
||||
[HttpPost("{userId}/Read")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult SetRead()
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets notifications as unread.
|
||||
/// </summary>
|
||||
/// <response code="204">Notifications set as unread.</response>
|
||||
/// <returns>A <cref see="NoContentResult"/>.</returns>
|
||||
[HttpPost("{userId}/Unread")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult SetUnread()
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -236,7 +236,9 @@ 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);
|
||||
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);
|
||||
@ -245,6 +247,7 @@ namespace Jellyfin.Api.Controllers
|
||||
|
||||
return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
|
||||
}
|
||||
}
|
||||
|
||||
return File(
|
||||
await EncodeSubtitles(
|
||||
@ -403,7 +406,9 @@ namespace Jellyfin.Api.Controllers
|
||||
{
|
||||
var video = (Video)_libraryManager.GetItemById(itemId);
|
||||
var data = Convert.FromBase64String(body.Data);
|
||||
await using var memoryStream = new MemoryStream(data);
|
||||
var memoryStream = new MemoryStream(data, 0, data.Length, false, true);
|
||||
await using (memoryStream.ConfigureAwait(false))
|
||||
{
|
||||
await _subtitleManager.UploadSubtitle(
|
||||
video,
|
||||
new SubtitleResponse
|
||||
@ -417,6 +422,7 @@ namespace Jellyfin.Api.Controllers
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a subtitle in the specified format.
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -2,7 +2,7 @@ using Jellyfin.Extensions.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Server.Formatters
|
||||
namespace Jellyfin.Api.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Camel Case Json Profile Formatter.
|
@ -3,7 +3,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
|
||||
namespace Jellyfin.Server.Formatters
|
||||
namespace Jellyfin.Api.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Css output formatter.
|
@ -3,7 +3,7 @@ using Jellyfin.Extensions.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Server.Formatters
|
||||
namespace Jellyfin.Api.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Pascal Case Json Profile Formatter.
|
@ -4,7 +4,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
|
||||
namespace Jellyfin.Server.Formatters
|
||||
namespace Jellyfin.Api.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Xml output formatter.
|
@ -181,7 +181,7 @@ namespace Jellyfin.Api.Helpers
|
||||
{
|
||||
var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
|
||||
|
||||
var options = new VideoOptions
|
||||
var options = new MediaOptions
|
||||
{
|
||||
MediaSources = new[] { mediaSource },
|
||||
Context = EncodingContext.Streaming,
|
||||
@ -244,8 +244,8 @@ namespace Jellyfin.Api.Helpers
|
||||
|
||||
// Beginning of Playback Determination
|
||||
var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
|
||||
? streamBuilder.BuildAudioItem(options)
|
||||
: streamBuilder.BuildVideoItem(options);
|
||||
? streamBuilder.GetOptimalAudioStream(options)
|
||||
: streamBuilder.GetOptimalVideoStream(options);
|
||||
|
||||
if (streamInfo is not null)
|
||||
{
|
||||
|
@ -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" />
|
||||
@ -31,7 +27,7 @@
|
||||
|
||||
<!-- Code Analyzers-->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -7,7 +7,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Redirect requests without baseurl prefix to the baseurl prefixed URL.
|
@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception Middleware.
|
@ -4,7 +4,7 @@ using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the IP of requests coming from local networks wrt. remote access.
|
@ -5,7 +5,7 @@ using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the LAN host IP based on application configuration.
|
@ -3,7 +3,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Removes /emby and /mediabrowser from requested route.
|
@ -2,7 +2,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// URL decodes the querystring before binding.
|
@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Response time middleware.
|
@ -3,7 +3,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Redirect requests to robots.txt to web/robots.txt.
|
@ -5,7 +5,7 @@ using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows a custom message during server startup.
|
@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="UrlDecodeQueryFeature"/>.
|
@ -2,7 +2,7 @@ using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Server.Middleware
|
||||
namespace Jellyfin.Api.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles WebSocket requests.
|
@ -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.
|
||||
|
@ -1,30 +0,0 @@
|
||||
using MediaBrowser.Model.Notifications;
|
||||
|
||||
namespace Jellyfin.Api.Models.NotificationDtos
|
||||
{
|
||||
/// <summary>
|
||||
/// The admin notification dto.
|
||||
/// </summary>
|
||||
public class AdminNotificationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the notification name.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification description.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification level.
|
||||
/// </summary>
|
||||
public NotificationLevel? NotificationLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification url.
|
||||
/// </summary>
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
using System;
|
||||
using MediaBrowser.Model.Notifications;
|
||||
|
||||
namespace Jellyfin.Api.Models.NotificationDtos
|
||||
{
|
||||
/// <summary>
|
||||
/// The notification DTO.
|
||||
/// </summary>
|
||||
public class NotificationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the notification ID. Defaults to an empty string.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification's user ID. Defaults to an empty string.
|
||||
/// </summary>
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification date.
|
||||
/// </summary>
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the notification has been read. Defaults to false.
|
||||
/// </summary>
|
||||
public bool IsRead { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification's name. Defaults to an empty string.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification's description. Defaults to an empty string.
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification's URL. Defaults to an empty string.
|
||||
/// </summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification level.
|
||||
/// </summary>
|
||||
public NotificationLevel Level { get; set; }
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Api.Models.NotificationDtos
|
||||
{
|
||||
/// <summary>
|
||||
/// A list of notifications with the total record count for pagination.
|
||||
/// </summary>
|
||||
public class NotificationResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current page of notifications.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NotificationDto> Notifications { get; set; } = Array.Empty<NotificationDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of notifications.
|
||||
/// </summary>
|
||||
public int TotalRecordCount { get; set; }
|
||||
}
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
using MediaBrowser.Model.Notifications;
|
||||
|
||||
namespace Jellyfin.Api.Models.NotificationDtos
|
||||
{
|
||||
/// <summary>
|
||||
/// The notification summary DTO.
|
||||
/// </summary>
|
||||
public class NotificationsSummaryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the number of unread notifications.
|
||||
/// </summary>
|
||||
public int UnreadCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum unread notification level.
|
||||
/// </summary>
|
||||
public NotificationLevel? MaxUnreadNotificationLevel { get; set; }
|
||||
}
|
||||
}
|
@ -29,7 +29,7 @@
|
||||
|
||||
<!-- Code analysers-->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@
|
||||
|
||||
<!-- Code Analyzers-->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -15,13 +15,13 @@ namespace Jellyfin.Server.Implementations.Activity
|
||||
/// </summary>
|
||||
public class ActivityManager : IActivityManager
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDb> _provider;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _provider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ActivityManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="provider">The Jellyfin database provider.</param>
|
||||
public ActivityManager(IDbContextFactory<JellyfinDb> provider)
|
||||
public ActivityManager(IDbContextFactory<JellyfinDbContext> provider)
|
||||
{
|
||||
_provider = provider;
|
||||
}
|
||||
@ -48,18 +48,10 @@ namespace Jellyfin.Server.Implementations.Activity
|
||||
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
IQueryable<ActivityLog> entries = dbContext.ActivityLogs
|
||||
.OrderByDescending(entry => entry.DateCreated);
|
||||
|
||||
if (query.MinDate.HasValue)
|
||||
{
|
||||
entries = entries.Where(entry => entry.DateCreated >= query.MinDate);
|
||||
}
|
||||
|
||||
if (query.HasUserId.HasValue)
|
||||
{
|
||||
entries = entries.Where(entry => (!entry.UserId.Equals(default)) == query.HasUserId.Value);
|
||||
}
|
||||
var entries = dbContext.ActivityLogs
|
||||
.OrderByDescending(entry => entry.DateCreated)
|
||||
.Where(entry => query.MinDate == null || entry.DateCreated >= query.MinDate)
|
||||
.Where(entry => !query.HasUserId.HasValue || entry.UserId.Equals(default) != query.HasUserId.Value);
|
||||
|
||||
return new QueryResult<ActivityLogEntry>(
|
||||
query.Skip,
|
||||
@ -67,8 +59,16 @@ namespace Jellyfin.Server.Implementations.Activity
|
||||
await entries
|
||||
.Skip(query.Skip ?? 0)
|
||||
.Take(query.Limit ?? 100)
|
||||
.AsAsyncEnumerable()
|
||||
.Select(ConvertToOldModel)
|
||||
.Select(entity => new ActivityLogEntry(entity.Name, entity.Type, entity.UserId)
|
||||
{
|
||||
Id = entity.Id,
|
||||
Overview = entity.Overview,
|
||||
ShortOverview = entity.ShortOverview,
|
||||
ItemId = entity.ItemId,
|
||||
Date = entity.DateCreated,
|
||||
Severity = entity.LogSeverity
|
||||
})
|
||||
.AsQueryable()
|
||||
.ToListAsync()
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
/// </summary>
|
||||
public class DeviceManager : IDeviceManager
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDb> _dbProvider;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new();
|
||||
|
||||
@ -32,7 +32,7 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
/// </summary>
|
||||
/// <param name="dbProvider">The database provider.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
public DeviceManager(IDbContextFactory<JellyfinDb> dbProvider, IUserManager userManager)
|
||||
public DeviceManager(IDbContextFactory<JellyfinDbContext> dbProvider, IUserManager userManager)
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_userManager = userManager;
|
||||
@ -54,7 +54,7 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
deviceOptions = await dbContext.DeviceOptions.AsQueryable().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false);
|
||||
deviceOptions = await dbContext.DeviceOptions.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false);
|
||||
if (deviceOptions is null)
|
||||
{
|
||||
deviceOptions = new DeviceOptions(deviceId);
|
||||
@ -132,22 +132,11 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var devices = dbContext.Devices.AsQueryable();
|
||||
|
||||
if (query.UserId.HasValue)
|
||||
{
|
||||
devices = devices.Where(device => device.UserId.Equals(query.UserId.Value));
|
||||
}
|
||||
|
||||
if (query.DeviceId is not null)
|
||||
{
|
||||
devices = devices.Where(device => device.DeviceId == query.DeviceId);
|
||||
}
|
||||
|
||||
if (query.AccessToken is not null)
|
||||
{
|
||||
devices = devices.Where(device => device.AccessToken == query.AccessToken);
|
||||
}
|
||||
var devices = dbContext.Devices
|
||||
.OrderBy(d => d.Id)
|
||||
.Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
|
||||
.Where(device => query.DeviceId == null || device.DeviceId == query.DeviceId)
|
||||
.Where(device => query.AccessToken == null || device.AccessToken == query.AccessToken);
|
||||
|
||||
var count = await devices.CountAsync().ConfigureAwait(false);
|
||||
|
||||
@ -179,11 +168,10 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
/// <inheritdoc />
|
||||
public async Task<QueryResult<DeviceInfo>> GetDevicesForUser(Guid? userId, bool? supportsSync)
|
||||
{
|
||||
IAsyncEnumerable<Device> sessions;
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
sessions = dbContext.Devices
|
||||
IAsyncEnumerable<Device> sessions = dbContext.Devices
|
||||
.Include(d => d.User)
|
||||
.OrderByDescending(d => d.DateLastActivity)
|
||||
.ThenBy(d => d.DeviceId)
|
||||
|
@ -4,7 +4,6 @@ using EFCoreSecondLevelCacheInterceptor;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Extensions;
|
||||
|
||||
@ -29,13 +28,11 @@ public static class ServiceCollectionExtensions
|
||||
.SkipCachingResults(result =>
|
||||
result.Value is null || (result.Value is EFTableRows rows && rows.RowsCount == 0)));
|
||||
|
||||
serviceCollection.AddPooledDbContextFactory<JellyfinDb>((serviceProvider, opt) =>
|
||||
serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) =>
|
||||
{
|
||||
var applicationPaths = serviceProvider.GetRequiredService<IApplicationPaths>();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}")
|
||||
.AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>())
|
||||
.UseLoggerFactory(loggerFactory);
|
||||
.AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>());
|
||||
});
|
||||
|
||||
return serviceCollection;
|
||||
|
@ -6,13 +6,9 @@
|
||||
<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">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
@ -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>
|
||||
|
@ -1,162 +0,0 @@
|
||||
#nullable disable
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Entities.Security;
|
||||
using Jellyfin.Data.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jellyfin.Server.Implementations
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public class JellyfinDb : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JellyfinDb"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">The database context options.</param>
|
||||
public JellyfinDb(DbContextOptions<JellyfinDb> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default connection string.
|
||||
/// </summary>
|
||||
public static string ConnectionString { get; set; } = @"Data Source=jellyfin.db";
|
||||
|
||||
public virtual DbSet<AccessSchedule> AccessSchedules { get; set; }
|
||||
|
||||
public virtual DbSet<ActivityLog> ActivityLogs { get; set; }
|
||||
|
||||
public virtual DbSet<ApiKey> ApiKeys { get; set; }
|
||||
|
||||
public virtual DbSet<Device> Devices { get; set; }
|
||||
|
||||
public virtual DbSet<DeviceOptions> DeviceOptions { get; set; }
|
||||
|
||||
public virtual DbSet<DisplayPreferences> DisplayPreferences { get; set; }
|
||||
|
||||
public virtual DbSet<ImageInfo> ImageInfos { get; set; }
|
||||
|
||||
public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; }
|
||||
|
||||
public virtual DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences { get; set; }
|
||||
|
||||
public virtual DbSet<Permission> Permissions { get; set; }
|
||||
|
||||
public virtual DbSet<Preference> Preferences { get; set; }
|
||||
|
||||
public virtual DbSet<User> Users { get; set; }
|
||||
|
||||
/*public virtual DbSet<Artwork> Artwork { get; set; }
|
||||
|
||||
public virtual DbSet<Book> Books { get; set; }
|
||||
|
||||
public virtual DbSet<BookMetadata> BookMetadata { get; set; }
|
||||
|
||||
public virtual DbSet<Chapter> Chapters { get; set; }
|
||||
|
||||
public virtual DbSet<Collection> Collections { get; set; }
|
||||
|
||||
public virtual DbSet<CollectionItem> CollectionItems { get; set; }
|
||||
|
||||
public virtual DbSet<Company> Companies { get; set; }
|
||||
|
||||
public virtual DbSet<CompanyMetadata> CompanyMetadata { get; set; }
|
||||
|
||||
public virtual DbSet<CustomItem> CustomItems { get; set; }
|
||||
|
||||
public virtual DbSet<CustomItemMetadata> CustomItemMetadata { get; set; }
|
||||
|
||||
public virtual DbSet<Episode> Episodes { get; set; }
|
||||
|
||||
public virtual DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
|
||||
|
||||
public virtual DbSet<Genre> Genres { get; set; }
|
||||
|
||||
public virtual DbSet<Group> Groups { get; set; }
|
||||
|
||||
public virtual DbSet<Library> Libraries { get; set; }
|
||||
|
||||
public virtual DbSet<LibraryItem> LibraryItems { get; set; }
|
||||
|
||||
public virtual DbSet<LibraryRoot> LibraryRoot { get; set; }
|
||||
|
||||
public virtual DbSet<MediaFile> MediaFiles { get; set; }
|
||||
|
||||
public virtual DbSet<MediaFileStream> MediaFileStream { get; set; }
|
||||
|
||||
public virtual DbSet<Metadata> Metadata { get; set; }
|
||||
|
||||
public virtual DbSet<MetadataProvider> MetadataProviders { get; set; }
|
||||
|
||||
public virtual DbSet<MetadataProviderId> MetadataProviderIds { get; set; }
|
||||
|
||||
public virtual DbSet<Movie> Movies { get; set; }
|
||||
|
||||
public virtual DbSet<MovieMetadata> MovieMetadata { get; set; }
|
||||
|
||||
public virtual DbSet<MusicAlbum> MusicAlbums { get; set; }
|
||||
|
||||
public virtual DbSet<MusicAlbumMetadata> MusicAlbumMetadata { get; set; }
|
||||
|
||||
public virtual DbSet<Person> People { get; set; }
|
||||
|
||||
public virtual DbSet<PersonRole> PersonRoles { get; set; }
|
||||
|
||||
public virtual DbSet<Photo> Photo { get; set; }
|
||||
|
||||
public virtual DbSet<PhotoMetadata> PhotoMetadata { get; set; }
|
||||
|
||||
public virtual DbSet<ProviderMapping> ProviderMappings { get; set; }
|
||||
|
||||
public virtual DbSet<Rating> Ratings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository for global::Jellyfin.Data.Entities.RatingSource - This is the entity to
|
||||
/// store review ratings, not age ratings.
|
||||
/// </summary>
|
||||
public virtual DbSet<RatingSource> RatingSources { get; set; }
|
||||
|
||||
public virtual DbSet<Release> Releases { get; set; }
|
||||
|
||||
public virtual DbSet<Season> Seasons { get; set; }
|
||||
|
||||
public virtual DbSet<SeasonMetadata> SeasonMetadata { get; set; }
|
||||
|
||||
public virtual DbSet<Series> Series { get; set; }
|
||||
|
||||
public virtual DbSet<SeriesMetadata> SeriesMetadata { get; set; }
|
||||
|
||||
public virtual DbSet<Track> Tracks { get; set; }
|
||||
|
||||
public virtual DbSet<TrackMetadata> TrackMetadata { get; set; }*/
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int SaveChanges()
|
||||
{
|
||||
foreach (var saveEntity in ChangeTracker.Entries()
|
||||
.Where(e => e.State == EntityState.Modified)
|
||||
.Select(entry => entry.Entity)
|
||||
.OfType<IHasConcurrencyToken>())
|
||||
{
|
||||
saveEntity.OnSavingChanges();
|
||||
}
|
||||
|
||||
return base.SaveChanges();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc);
|
||||
base.OnModelCreating(modelBuilder);
|
||||
modelBuilder.HasDefaultSchema("jellyfin");
|
||||
|
||||
// Configuration for each entity is in it's own class inside 'ModelConfiguration'.
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(JellyfinDb).Assembly);
|
||||
}
|
||||
}
|
||||
}
|
188
Jellyfin.Server.Implementations/JellyfinDbContext.cs
Normal file
188
Jellyfin.Server.Implementations/JellyfinDbContext.cs
Normal file
@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Entities.Security;
|
||||
using Jellyfin.Data.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jellyfin.Server.Implementations;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public class JellyfinDbContext : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JellyfinDbContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">The database context options.</param>
|
||||
public JellyfinDbContext(DbContextOptions<JellyfinDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the access schedules.
|
||||
/// </summary>
|
||||
public DbSet<AccessSchedule> AccessSchedules => Set<AccessSchedule>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the activity logs.
|
||||
/// </summary>
|
||||
public DbSet<ActivityLog> ActivityLogs => Set<ActivityLog>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the API keys.
|
||||
/// </summary>
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the devices.
|
||||
/// </summary>
|
||||
public DbSet<Device> Devices => Set<Device>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the device options.
|
||||
/// </summary>
|
||||
public DbSet<DeviceOptions> DeviceOptions => Set<DeviceOptions>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the display preferences.
|
||||
/// </summary>
|
||||
public DbSet<DisplayPreferences> DisplayPreferences => Set<DisplayPreferences>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the image infos.
|
||||
/// </summary>
|
||||
public DbSet<ImageInfo> ImageInfos => Set<ImageInfo>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the item display preferences.
|
||||
/// </summary>
|
||||
public DbSet<ItemDisplayPreferences> ItemDisplayPreferences => Set<ItemDisplayPreferences>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the custom item display preferences.
|
||||
/// </summary>
|
||||
public DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences => Set<CustomItemDisplayPreferences>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the permissions.
|
||||
/// </summary>
|
||||
public DbSet<Permission> Permissions => Set<Permission>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the preferences.
|
||||
/// </summary>
|
||||
public DbSet<Preference> Preferences => Set<Preference>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the users.
|
||||
/// </summary>
|
||||
public DbSet<User> Users => Set<User>();
|
||||
|
||||
/*public DbSet<Artwork> Artwork => Set<Artwork>();
|
||||
|
||||
public DbSet<Book> Books => Set<Book>();
|
||||
|
||||
public DbSet<BookMetadata> BookMetadata => Set<BookMetadata>();
|
||||
|
||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||
|
||||
public DbSet<Collection> Collections => Set<Collection>();
|
||||
|
||||
public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>();
|
||||
|
||||
public DbSet<Company> Companies => Set<Company>();
|
||||
|
||||
public DbSet<CompanyMetadata> CompanyMetadata => Set<CompanyMetadata>();
|
||||
|
||||
public DbSet<CustomItem> CustomItems => Set<CustomItem>();
|
||||
|
||||
public DbSet<CustomItemMetadata> CustomItemMetadata => Set<CustomItemMetadata>();
|
||||
|
||||
public DbSet<Episode> Episodes => Set<Episode>();
|
||||
|
||||
public DbSet<EpisodeMetadata> EpisodeMetadata => Set<EpisodeMetadata>();
|
||||
|
||||
public DbSet<Genre> Genres => Set<Genre>();
|
||||
|
||||
public DbSet<Group> Groups => Set<Groups>();
|
||||
|
||||
public DbSet<Library> Libraries => Set<Library>();
|
||||
|
||||
public DbSet<LibraryItem> LibraryItems => Set<LibraryItems>();
|
||||
|
||||
public DbSet<LibraryRoot> LibraryRoot => Set<LibraryRoot>();
|
||||
|
||||
public DbSet<MediaFile> MediaFiles => Set<MediaFiles>();
|
||||
|
||||
public DbSet<MediaFileStream> MediaFileStream => Set<MediaFileStream>();
|
||||
|
||||
public DbSet<Metadata> Metadata => Set<Metadata>();
|
||||
|
||||
public DbSet<MetadataProvider> MetadataProviders => Set<MetadataProvider>();
|
||||
|
||||
public DbSet<MetadataProviderId> MetadataProviderIds => Set<MetadataProviderId>();
|
||||
|
||||
public DbSet<Movie> Movies => Set<Movie>();
|
||||
|
||||
public DbSet<MovieMetadata> MovieMetadata => Set<MovieMetadata>();
|
||||
|
||||
public DbSet<MusicAlbum> MusicAlbums => Set<MusicAlbum>();
|
||||
|
||||
public DbSet<MusicAlbumMetadata> MusicAlbumMetadata => Set<MusicAlbumMetadata>();
|
||||
|
||||
public DbSet<Person> People => Set<Person>();
|
||||
|
||||
public DbSet<PersonRole> PersonRoles => Set<PersonRole>();
|
||||
|
||||
public DbSet<Photo> Photo => Set<Photo>();
|
||||
|
||||
public DbSet<PhotoMetadata> PhotoMetadata => Set<PhotoMetadata>();
|
||||
|
||||
public DbSet<ProviderMapping> ProviderMappings => Set<ProviderMapping>();
|
||||
|
||||
public DbSet<Rating> Ratings => Set<Rating>();
|
||||
|
||||
/// <summary>
|
||||
/// Repository for global::Jellyfin.Data.Entities.RatingSource - This is the entity to
|
||||
/// store review ratings, not age ratings.
|
||||
/// </summary>
|
||||
public DbSet<RatingSource> RatingSources => Set<RatingSource>();
|
||||
|
||||
public DbSet<Release> Releases => Set<Release>();
|
||||
|
||||
public DbSet<Season> Seasons => Set<Season>();
|
||||
|
||||
public DbSet<SeasonMetadata> SeasonMetadata => Set<SeasonMetadata>();
|
||||
|
||||
public DbSet<Series> Series => Set<Series>();
|
||||
|
||||
public DbSet<SeriesMetadata> SeriesMetadata => Set<SeriesMetadata();
|
||||
|
||||
public DbSet<Track> Tracks => Set<Track>();
|
||||
|
||||
public DbSet<TrackMetadata> TrackMetadata => Set<TrackMetadata>();*/
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int SaveChanges()
|
||||
{
|
||||
foreach (var saveEntity in ChangeTracker.Entries()
|
||||
.Where(e => e.State == EntityState.Modified)
|
||||
.Select(entry => entry.Entity)
|
||||
.OfType<IHasConcurrencyToken>())
|
||||
{
|
||||
saveEntity.OnSavingChanges();
|
||||
}
|
||||
|
||||
return base.SaveChanges();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc);
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Configuration for each entity is in it's own class inside 'ModelConfiguration'.
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(JellyfinDbContext).Assembly);
|
||||
}
|
||||
}
|
@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
[DbContext(typeof(JellyfinDb))]
|
||||
[DbContext(typeof(JellyfinDbContext))]
|
||||
[Migration("20200514181226_AddActivityLog")]
|
||||
partial class AddActivityLog
|
||||
{
|
||||
|
@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
[DbContext(typeof(JellyfinDb))]
|
||||
[DbContext(typeof(JellyfinDbContext))]
|
||||
[Migration("20200613202153_AddUsers")]
|
||||
partial class AddUsers
|
||||
{
|
||||
|
@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
[DbContext(typeof(JellyfinDb))]
|
||||
[DbContext(typeof(JellyfinDbContext))]
|
||||
[Migration("20200728005145_AddDisplayPreferences")]
|
||||
partial class AddDisplayPreferences
|
||||
{
|
||||
|
@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
[DbContext(typeof(JellyfinDb))]
|
||||
[DbContext(typeof(JellyfinDbContext))]
|
||||
[Migration("20200905220533_FixDisplayPreferencesIndex")]
|
||||
partial class FixDisplayPreferencesIndex
|
||||
{
|
||||
|
@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
[DbContext(typeof(JellyfinDb))]
|
||||
[DbContext(typeof(JellyfinDbContext))]
|
||||
[Migration("20201004171403_AddMaxActiveSessions")]
|
||||
partial class AddMaxActiveSessions
|
||||
{
|
||||
|
@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
[DbContext(typeof(JellyfinDb))]
|
||||
[DbContext(typeof(JellyfinDbContext))]
|
||||
[Migration("20201204223655_AddCustomDisplayPreferences")]
|
||||
partial class AddCustomDisplayPreferences
|
||||
{
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user