Merge remote-tracking branch 'upstream/master' into network-rewrite

This commit is contained in:
Shadowghost 2023-01-19 10:09:32 +01:00
commit 656a0bff6f
234 changed files with 4155 additions and 3929 deletions

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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
@ -22,7 +23,7 @@ jobs:
stale-issue-label: stale
stale-issue-message: |-
This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).

View File

@ -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)

View File

@ -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(),

View File

@ -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>

View File

@ -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,

View File

@ -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",

View File

@ -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;
}
}
}

View File

@ -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();
}
}
}

View File

@ -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",
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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);

View File

@ -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)

View File

@ -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>

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -1814,21 +1814,29 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
program.AddGenre("News");
}
if (timer.IsProgramSeries)
var config = GetConfiguration();
if (config.SaveRecordingNFO)
{
await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
}
else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
{
await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false);
}
else
{
await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
if (timer.IsProgramSeries)
{
await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
}
else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
{
await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false);
}
else
{
await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
}
}
await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
if (config.SaveRecordingImages)
{
await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
}
}
catch (Exception ex)
{

View File

@ -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"
}

View File

@ -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": "ضعاف السمع"
}

View File

@ -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 descanejar 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.",

View File

@ -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": "Προεπιλογή",

View File

@ -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",

View File

@ -122,5 +122,6 @@
"TaskOptimizeDatabase": "データベースの最適化",
"TaskKeyframeExtractorDescription": "より正確なHLSプレイリストを作成するため、動画ファイルからキーフレームを抽出する。この処理には時間がかかる場合があります。",
"TaskKeyframeExtractor": "キーフレーム抽出",
"External": "外部"
"External": "外部",
"HearingImpaired": "聴覚障害の方"
}

View File

@ -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": "ბაზს შეკუშვა და ადგილის გათავისუფლება. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს."
}

View File

@ -123,5 +123,6 @@
"TaskOptimizeDatabase": "데이터베이스 최적화",
"TaskKeyframeExtractorDescription": "비디오 파일에서 키프레임을 추출하여 더 정확한 HLS 재생 목록을 만듭니다. 이 작업은 오랫동안 진행될 수 있습니다.",
"TaskKeyframeExtractor": "키프레임 추출",
"External": "외부"
"External": "외부",
"HearingImpaired": "청각 장애"
}

View File

@ -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"
}

View File

@ -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}"
}

View File

@ -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": "Плагин удалён",

View File

@ -122,5 +122,6 @@
"TaskOptimizeDatabaseDescription": "Сажима базу података и скраћује слободан простор. Покретање овог задатка након скенирања библиотеке или других промена које подразумевају измене базе података које могу побољшати перформансе.",
"External": "Спољно",
"TaskKeyframeExtractorDescription": "Екстрактује кљулне сличице из видео датотека да би креирао више преицзну HLS плеј-листу. Овај задатак може да потраје дуже време.",
"TaskKeyframeExtractor": "Екстрактор кључних сличица"
"TaskKeyframeExtractor": "Екстрактор кључних сличица",
"HearingImpaired": "ослабљен слух"
}

View File

@ -120,5 +120,6 @@
"Forced": "บังคับใช้",
"TaskOptimizeDatabase": "ปรับปรุงประสิทธิภาพฐานข้อมูล",
"TaskOptimizeDatabaseDescription": "ลดขนาดการจัดเก็บฐานข้อมูล ใช้งานคำสั่งนี้หลังจากสแกนไลบรารีหรือหลังจากการเปลี่ยนแปลงฐานข้อมูล อาจจะทำให้ระบบทำงานเร็วขึ้น",
"External": "ภายนอก"
"External": "ภายนอก",
"HearingImpaired": "บกพร่องทางการได้ยิน"
}

View File

@ -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": "已添加新内容",

View File

@ -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

View 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;
}
}

View File

@ -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>

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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,24 +105,26 @@ namespace Jellyfin.Api.Controllers
}
var user = _userManager.GetUserById(userId);
await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
if (user.ProfileImage is not null)
var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
await using (memoryStream.ConfigureAwait(false))
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
if (user.ProfileImage is not null)
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
}
user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
await _providerManager
.SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
.ConfigureAwait(false);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
return NoContent();
}
user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
await _providerManager
.SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
.ConfigureAwait(false);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
return NoContent();
}
/// <summary>
@ -153,24 +154,26 @@ namespace Jellyfin.Api.Controllers
}
var user = _userManager.GetUserById(userId);
await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
if (user.ProfileImage is not null)
var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
await using (memoryStream.ConfigureAwait(false))
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
if (user.ProfileImage is not null)
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
}
user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
await _providerManager
.SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
.ConfigureAwait(false);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
return NoContent();
}
user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
await _providerManager
.SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
.ConfigureAwait(false);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
return NoContent();
}
/// <summary>
@ -341,14 +344,16 @@ 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);
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
return NoContent();
return NoContent();
}
}
/// <summary>
@ -377,14 +382,16 @@ 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);
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
return NoContent();
return NoContent();
}
}
/// <summary>
@ -1788,32 +1795,35 @@ namespace Jellyfin.Api.Controllers
[AcceptsImageFile]
public async Task<ActionResult> UploadCustomSplashscreen()
{
await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
if (!mimeType.HasValue)
var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
await using (memoryStream.ConfigureAwait(false))
{
return BadRequest("Error reading mimetype from uploaded image");
var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
if (!mimeType.HasValue)
{
return BadRequest("Error reading mimetype from uploaded image");
}
var extension = MimeTypes.ToExtension(mimeType.Value);
if (string.IsNullOrEmpty(extension))
{
return BadRequest("Error converting mimetype to an image extension");
}
var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
brandingOptions.SplashscreenLocation = filePath;
_serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
await using (fs.ConfigureAwait(false))
{
await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
}
return NoContent();
}
var extension = MimeTypes.ToExtension(mimeType.Value);
if (string.IsNullOrEmpty(extension))
{
return BadRequest("Error converting mimetype to an image extension");
}
var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
brandingOptions.SplashscreenLocation = filePath;
_serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
await using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous))
{
await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
}
return NoContent();
}
/// <summary>
@ -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()))
{

View File

@ -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);

View File

@ -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();
}
}
}

View File

@ -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();

View File

@ -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();

View File

@ -236,14 +236,17 @@ namespace Jellyfin.Api.Controllers
if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
{
await using Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
using var reader = new StreamReader(stream);
Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
using var reader = new StreamReader(stream);
var text = await reader.ReadToEndAsync().ConfigureAwait(false);
var text = await reader.ReadToEndAsync().ConfigureAwait(false);
text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal);
text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal);
return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
}
}
return File(
@ -403,19 +406,22 @@ namespace Jellyfin.Api.Controllers
{
var video = (Video)_libraryManager.GetItemById(itemId);
var data = Convert.FromBase64String(body.Data);
await using var memoryStream = new MemoryStream(data);
await _subtitleManager.UploadSubtitle(
video,
new SubtitleResponse
{
Format = body.Format,
Language = body.Language,
IsForced = body.IsForced,
Stream = memoryStream
}).ConfigureAwait(false);
_providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
var memoryStream = new MemoryStream(data, 0, data.Length, false, true);
await using (memoryStream.ConfigureAwait(false))
{
await _subtitleManager.UploadSubtitle(
video,
new SubtitleResponse
{
Format = body.Format,
Language = body.Language,
IsForced = body.IsForced,
Stream = memoryStream
}).ConfigureAwait(false);
_providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
return NoContent();
return NoContent();
}
}
/// <summary>

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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()

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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)
{

View File

@ -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>

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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"/>.

View File

@ -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.

View File

@ -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.

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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>

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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());
}
}

View File

@ -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");
}
}
}
}

View File

@ -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)
{
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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>

View File

@ -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));
}

View File

@ -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)

View File

@ -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;

View File

@ -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>

View File

@ -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);
}
}
}

View 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);
}
}

View File

@ -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
{

View File

@ -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
{

View File

@ -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
{

View File

@ -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
{

View File

@ -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
{

View File

@ -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