mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-13 12:16:49 +01:00
Compare commits
119 Commits
renovate/b
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8435dfb779 | ||
|
|
e9942c3857 | ||
|
|
04ecf77d97 | ||
|
|
b9ee9b0660 | ||
|
|
27a3ccb7e4 | ||
|
|
a9865367d8 | ||
|
|
c169184e01 | ||
|
|
5bcea608c5 | ||
|
|
22bf421be6 | ||
|
|
6d3a7b6f69 | ||
|
|
406aaabefd | ||
|
|
ef64f19eac | ||
|
|
2800ae3b8a | ||
|
|
7ead333f6e | ||
|
|
9be1246749 | ||
|
|
2229851689 | ||
|
|
21f12a1ad0 | ||
|
|
be0f0b9761 | ||
|
|
f24709f11c | ||
|
|
4f238ca9b3 | ||
|
|
42870986a8 | ||
|
|
82120732ca | ||
|
|
9a3dfec151 | ||
|
|
02835c6144 | ||
|
|
6d6dee9492 | ||
|
|
fc251265d9 | ||
|
|
5cb6ac521a | ||
|
|
4dd19b990b | ||
|
|
efa502a65f | ||
|
|
b7b405dc83 | ||
|
|
149649a6cf | ||
|
|
169745fddb | ||
|
|
c4c521719e | ||
|
|
e9cad048e9 | ||
|
|
de64a69c7a | ||
|
|
c328dbef10 | ||
|
|
8437866ffa | ||
|
|
b84bd34c67 | ||
|
|
82b51a60a5 | ||
|
|
5cdfb9bfac | ||
|
|
6c59f9a03d | ||
|
|
e1e18e8da0 | ||
|
|
63a078b720 | ||
|
|
d636b82e83 | ||
|
|
bc074b5283 | ||
|
|
3876a0ad3d | ||
|
|
a629080c89 | ||
|
|
2fbb821581 | ||
|
|
33ed52b8ee | ||
|
|
d1ab428476 | ||
|
|
142b89eab5 | ||
|
|
1bbbc1c823 | ||
|
|
a8f361f8c0 | ||
|
|
c0593281ff | ||
|
|
d648aba881 | ||
|
|
44d5954205 | ||
|
|
10c42d70ca | ||
|
|
da88a06ede | ||
|
|
842a5efdcf | ||
|
|
e84fd95bcc | ||
|
|
28546f535c | ||
|
|
e50aeb914a | ||
|
|
087e745e7a | ||
|
|
c8c890b9e5 | ||
|
|
4b8be6bc91 | ||
|
|
ec054f6a34 | ||
|
|
d3e6079d38 | ||
|
|
9ade1fb8f6 | ||
|
|
8a43b1c784 | ||
|
|
4178e0ebaf | ||
|
|
064fd8c5c0 | ||
|
|
7be1350205 | ||
|
|
b227f3e85b | ||
|
|
fd6badf096 | ||
|
|
7e4727fff8 | ||
|
|
6f2e42c20c | ||
|
|
d4f91ab5ca | ||
|
|
0d58c773f9 | ||
|
|
6be96100c7 | ||
|
|
57c0fcd674 | ||
|
|
6ea2f05497 | ||
|
|
2365cea626 | ||
|
|
88cad2ad1a | ||
|
|
b8e25b49b3 | ||
|
|
68ab585894 | ||
|
|
c483619928 | ||
|
|
b717754ed8 | ||
|
|
31e8e197cf | ||
|
|
60e01e1f22 | ||
|
|
e0f50f504a | ||
|
|
3ef7ada736 | ||
|
|
a12736a0ce | ||
|
|
3a4dff8cc4 | ||
|
|
f793acc1aa | ||
|
|
aa96ff42e6 | ||
|
|
e065015d6d | ||
|
|
d5fb6f99ef | ||
|
|
3d4e4c4572 | ||
|
|
11e16df596 | ||
|
|
febfd7f94a | ||
|
|
8271568677 | ||
|
|
b22c8882d6 | ||
|
|
63c4fc297a | ||
|
|
e70eaf8bc1 | ||
|
|
b7da5c1860 | ||
|
|
41d2070008 | ||
|
|
100d6bb38c | ||
|
|
d63b2b2657 | ||
|
|
ed43ad0968 | ||
|
|
27396bffc6 | ||
|
|
d156e04c9a | ||
|
|
97340edf02 | ||
|
|
c4c3e9ea4d | ||
|
|
679664ca28 | ||
|
|
b0eec00e1c | ||
|
|
e49d71707c | ||
|
|
893188ab28 | ||
|
|
a014cb538e | ||
|
|
378ba937b6 |
6
.github/workflows/ci-codeql-analysis.yml
vendored
6
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -32,13 +32,13 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
|
||||
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@c31aa4ed4f12f147061186cf2a029f307b5c3636 # v5.5.9
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
- [Phlogi](https://github.com/Phlogi)
|
||||
- [pjeanjean](https://github.com/pjeanjean)
|
||||
- [ploughpuff](https://github.com/ploughpuff)
|
||||
- [poytiis](https://github.com/poytiis)
|
||||
- [pR0Ps](https://github.com/pR0Ps)
|
||||
- [PrplHaz4](https://github.com/PrplHaz4)
|
||||
- [RazeLighter777](https://github.com/RazeLighter777)
|
||||
@@ -229,6 +230,7 @@
|
||||
- [LiHRaM](https://github.com/LiHRaM)
|
||||
- [MSalman5230](https://github.com/MSalman5230)
|
||||
- [dwandw](https://github.com/dwandw)
|
||||
- [Lampan-git](https://github.com/Lampan-git)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
<PackageVersion Include="BDInfo" Version="0.8.0" />
|
||||
<PackageVersion Include="BitFaster.Caching" Version="2.6.0" />
|
||||
<PackageVersion Include="BitFaster.Caching" Version="2.5.4" />
|
||||
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||
@@ -26,27 +26,27 @@
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||
@@ -77,7 +77,7 @@
|
||||
<PackageVersion Include="Svg.Skia" Version="3.4.1" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.1.7" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.7" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.13.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#pragma warning disable CA1815
|
||||
|
||||
namespace Emby.Naming.AudioBook
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Naming</PackageId>
|
||||
<VersionPrefix>10.12.0</VersionPrefix>
|
||||
<VersionPrefix>12.0.0</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -12,10 +12,10 @@ namespace Emby.Naming.TV
|
||||
{
|
||||
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
|
||||
|
||||
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ProcessPre();
|
||||
|
||||
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ProcessPost();
|
||||
|
||||
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#pragma warning disable CA1815
|
||||
|
||||
namespace Emby.Naming.Video
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -166,8 +166,6 @@ namespace Emby.Server.Implementations
|
||||
ConfigurationManager.Configuration,
|
||||
ApplicationPaths.PluginsPath,
|
||||
ApplicationVersion);
|
||||
|
||||
_disposableParts.Add(_pluginManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1014,6 +1012,8 @@ namespace Emby.Server.Implementations
|
||||
}
|
||||
|
||||
_disposableParts.Clear();
|
||||
|
||||
_pluginManager?.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
@@ -203,6 +203,39 @@ namespace Emby.Server.Implementations.Dto
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch MusicArtist lookups across all items to avoid N+1 queries.
|
||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null;
|
||||
var artistNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var item in accessibleItems)
|
||||
{
|
||||
if (item is IHasArtist hasArtist)
|
||||
{
|
||||
foreach (var name in hasArtist.Artists)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
artistNames.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||
{
|
||||
foreach (var name in hasAlbumArtist.AlbumArtists)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
artistNames.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artistNames.Count > 0)
|
||||
{
|
||||
artistsBatch = _libraryManager.GetArtists(artistNames.ToArray());
|
||||
}
|
||||
|
||||
for (int index = 0; index < accessibleItems.Count; index++)
|
||||
{
|
||||
var item = accessibleItems[index];
|
||||
@@ -214,7 +247,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
userDataBatch?.GetValueOrDefault(item.Id),
|
||||
allCollectionFolders,
|
||||
childCountBatch,
|
||||
playedCountBatch);
|
||||
playedCountBatch,
|
||||
artistsBatch);
|
||||
|
||||
if (item is LiveTvChannel tvChannel)
|
||||
{
|
||||
@@ -274,7 +308,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
UserItemData? userData = null,
|
||||
List<Folder>? allCollectionFolders = null,
|
||||
Dictionary<Guid, int>? childCountBatch = null,
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
|
||||
{
|
||||
var dto = new BaseItemDto
|
||||
{
|
||||
@@ -334,7 +369,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
AttachStudios(dto, item);
|
||||
}
|
||||
|
||||
AttachBasicFields(dto, item, owner, options);
|
||||
AttachBasicFields(dto, item, owner, options, artistsBatch);
|
||||
|
||||
if (options.ContainsField(ItemFields.CanDelete))
|
||||
{
|
||||
@@ -907,7 +942,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="owner">The owner.</param>
|
||||
/// <param name="options">The options.</param>
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options)
|
||||
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
|
||||
{
|
||||
if (options.ContainsField(ItemFields.DateCreated))
|
||||
{
|
||||
@@ -1031,6 +1067,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
dto.OriginalTitle = item.OriginalTitle;
|
||||
}
|
||||
|
||||
dto.OriginalLanguage = item.OriginalLanguage;
|
||||
|
||||
if (options.ContainsField(ItemFields.ParentId))
|
||||
{
|
||||
dto.ParentId = item.DisplayParentId;
|
||||
@@ -1152,7 +1190,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
var artistsLookup = artistsBatch
|
||||
?? _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.ArtistItems = hasArtist.Artists
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
@@ -1186,7 +1225,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
// })
|
||||
// .ToList();
|
||||
|
||||
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
var albumArtistsLookup = artistsBatch
|
||||
?? _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
|
||||
@@ -23,6 +23,7 @@ using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
@@ -423,7 +424,7 @@ namespace Emby.Server.Implementations.Library
|
||||
MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage);
|
||||
}
|
||||
|
||||
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
|
||||
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection, string originalLanguage)
|
||||
{
|
||||
if (userData is not null && userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection)
|
||||
{
|
||||
@@ -437,7 +438,42 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference);
|
||||
if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
originalLanguage = !string.IsNullOrWhiteSpace(originalLanguage)
|
||||
? originalLanguage.Split(',').FirstOrDefault()
|
||||
: null;
|
||||
|
||||
if (user.PlayDefaultAudioTrack)
|
||||
{
|
||||
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(
|
||||
source.MediaStreams,
|
||||
NormalizeLanguage(originalLanguage),
|
||||
user.PlayDefaultAudioTrack);
|
||||
return;
|
||||
}
|
||||
|
||||
var originalIndex = source.MediaStreams.FindIndex(i => i.Type == MediaStreamType.Audio && i.IsOriginal);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(originalLanguage) && originalIndex != -1)
|
||||
{
|
||||
var mediaLanguageOriginal = source.MediaStreams[originalIndex].Language;
|
||||
if (NormalizeLanguage(mediaLanguageOriginal).Contains(NormalizeLanguage(originalLanguage).FirstOrDefault()))
|
||||
{
|
||||
source.DefaultAudioStreamIndex = originalIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (originalIndex != -1)
|
||||
{
|
||||
source.DefaultAudioStreamIndex = originalIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var preferredAudio = string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(originalLanguage)
|
||||
? NormalizeLanguage(originalLanguage)
|
||||
: NormalizeLanguage(user.AudioLanguagePreference);
|
||||
|
||||
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack);
|
||||
if (user.PlayDefaultAudioTrack)
|
||||
@@ -462,7 +498,19 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections;
|
||||
|
||||
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection);
|
||||
var originalLanguage = item?.OriginalLanguage ?? item switch
|
||||
{
|
||||
Episode episode => episode.Series.OriginalLanguage,
|
||||
Video video => video.GetOwner() switch
|
||||
{
|
||||
Episode ownerEpisode => ownerEpisode.OriginalLanguage ?? ownerEpisode.Series.OriginalLanguage,
|
||||
BaseItem owner => owner.OriginalLanguage,
|
||||
null => null
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
|
||||
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage);
|
||||
SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);
|
||||
}
|
||||
else if (mediaType == MediaType.Audio)
|
||||
|
||||
@@ -70,6 +70,16 @@ namespace Emby.Server.Implementations.Library
|
||||
return match ? imdbId.ToString() : null;
|
||||
}
|
||||
|
||||
// Allow tmdb as an alias for tmdbid
|
||||
if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var tmdbValue = str.GetAttributeValue("tmdb");
|
||||
if (tmdbValue is not null)
|
||||
{
|
||||
return tmdbValue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library;
|
||||
|
||||
@@ -14,18 +15,22 @@ namespace Emby.Server.Implementations.Library;
|
||||
/// </summary>
|
||||
public class PathManager : IPathManager
|
||||
{
|
||||
private readonly ILogger<PathManager> _logger;
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PathManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="config">The server configuration manager.</param>
|
||||
/// <param name="appPaths">The application paths.</param>
|
||||
public PathManager(
|
||||
ILogger<PathManager> logger,
|
||||
IServerConfigurationManager config,
|
||||
IApplicationPaths appPaths)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_appPaths = appPaths;
|
||||
}
|
||||
@@ -35,31 +40,43 @@ public class PathManager : IPathManager
|
||||
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetAttachmentPath(string mediaSourceId, string fileName)
|
||||
public string? GetAttachmentPath(string mediaSourceId, string fileName)
|
||||
{
|
||||
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName);
|
||||
var folder = GetAttachmentFolderPath(mediaSourceId);
|
||||
return folder is null ? null : Path.Combine(folder, fileName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetAttachmentFolderPath(string mediaSourceId)
|
||||
public string? GetAttachmentFolderPath(string mediaSourceId)
|
||||
{
|
||||
var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||
if (!Guid.TryParse(mediaSourceId, out var parsed))
|
||||
{
|
||||
_logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk attachment folder.", mediaSourceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||
return Path.Join(AttachmentCachePath, id[..2], id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetSubtitleFolderPath(string mediaSourceId)
|
||||
public string? GetSubtitleFolderPath(string mediaSourceId)
|
||||
{
|
||||
var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||
if (!Guid.TryParse(mediaSourceId, out var parsed))
|
||||
{
|
||||
_logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk subtitle folder.", mediaSourceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||
return Path.Join(SubtitleCachePath, id[..2], id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
|
||||
public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
|
||||
{
|
||||
return Path.Combine(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
|
||||
var folder = GetSubtitleFolderPath(mediaSourceId);
|
||||
return folder is null ? null : Path.Combine(folder, streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -90,12 +107,23 @@ public class PathManager : IPathManager
|
||||
public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item)
|
||||
{
|
||||
var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
return [
|
||||
GetAttachmentFolderPath(mediaSourceId),
|
||||
GetSubtitleFolderPath(mediaSourceId),
|
||||
GetTrickplayDirectory(item, false),
|
||||
GetTrickplayDirectory(item, true),
|
||||
GetChapterImageFolderPath(item)
|
||||
];
|
||||
List<string> paths = [];
|
||||
var attachmentFolder = GetAttachmentFolderPath(mediaSourceId);
|
||||
if (attachmentFolder is not null)
|
||||
{
|
||||
paths.Add(attachmentFolder);
|
||||
}
|
||||
|
||||
var subtitleFolder = GetSubtitleFolderPath(mediaSourceId);
|
||||
if (subtitleFolder is not null)
|
||||
{
|
||||
paths.Add(subtitleFolder);
|
||||
}
|
||||
|
||||
paths.Add(GetTrickplayDirectory(item, false));
|
||||
paths.Add(GetTrickplayDirectory(item, true));
|
||||
paths.Add(GetChapterImageFolderPath(item));
|
||||
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Emby.Naming.Common;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -81,10 +83,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
episode.ParentIndexNumber = 1;
|
||||
}
|
||||
|
||||
SetProviderIdFromPath(episode, args.Path);
|
||||
|
||||
return episode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets provider ids from the episode file name.
|
||||
/// </summary>
|
||||
/// <param name="item">The episode.</param>
|
||||
/// <param name="path">The episode file path.</param>
|
||||
private static void SetProviderIdFromPath(Episode item, string path)
|
||||
{
|
||||
var justName = Path.GetFileNameWithoutExtension(path.AsSpan());
|
||||
|
||||
var imdbId = justName.GetAttributeValue("imdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Imdb, imdbId);
|
||||
|
||||
var tvdbId = justName.GetAttributeValue("tvdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
|
||||
|
||||
var tvmazeId = justName.GetAttributeValue("tvmazeid");
|
||||
item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
|
||||
|
||||
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Emby.Naming.Common;
|
||||
using Emby.Naming.TV;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -77,6 +82,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var hasAnyVideo = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
|
||||
.Any(file => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(file)));
|
||||
|
||||
if (!hasAnyVideo)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name))
|
||||
@@ -91,10 +104,31 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
args.LibraryOptions.PreferredMetadataLanguage);
|
||||
}
|
||||
|
||||
SetProviderIdFromPath(season, path);
|
||||
|
||||
return season;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets provider ids from the season folder name.
|
||||
/// </summary>
|
||||
/// <param name="item">The season.</param>
|
||||
/// <param name="path">The season folder path.</param>
|
||||
private static void SetProviderIdFromPath(Season item, string path)
|
||||
{
|
||||
var justName = Path.GetFileName(path.AsSpan());
|
||||
|
||||
var tvdbId = justName.GetAttributeValue("tvdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
|
||||
|
||||
var tvmazeId = justName.GetAttributeValue("tvmazeid");
|
||||
item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
|
||||
|
||||
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
|
||||
"TaskMoveTrickplayImagesDescription": "ينقل ملفات معاينات التنقل الحالية وفقاً لإعدادات المكتبة.",
|
||||
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
|
||||
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل."
|
||||
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
|
||||
"Original": "فريد"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny.",
|
||||
"CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.",
|
||||
"CleanupUserDataTask": "Pročistit uživatelská data"
|
||||
"CleanupUserDataTask": "Pročistit uživatelská data",
|
||||
"Original": "Originál"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren",
|
||||
"TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.",
|
||||
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
|
||||
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind."
|
||||
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"NotificationOptionUserLockedOut": "User locked out",
|
||||
"NotificationOptionVideoPlayback": "Video playback started",
|
||||
"NotificationOptionVideoPlaybackStopped": "Video playback stopped",
|
||||
"Original": "Original",
|
||||
"Photos": "Photos",
|
||||
"Playlists": "Playlists",
|
||||
"Plugin": "Plugin",
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días."
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskCleanTranscode": "Eolaire Transcode Glan",
|
||||
"TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh",
|
||||
"CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora",
|
||||
"CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad."
|
||||
"CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.",
|
||||
"Original": "Bunaidh"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskMoveTrickplayImages": "Premjesti mjesto slika brzog pregledavanja",
|
||||
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.",
|
||||
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
|
||||
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana."
|
||||
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"MixedContent": "Vegyes tartalom",
|
||||
"Movies": "Filmek",
|
||||
"Music": "Zenék",
|
||||
"MusicVideos": "Zenei videóklipek",
|
||||
"MusicVideos": "Zenei videók",
|
||||
"NameInstallFailed": "{0} sikertelen telepítés",
|
||||
"NameSeasonNumber": "{0}. évad",
|
||||
"NameSeasonUnknown": "Ismeretlen évad",
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.",
|
||||
"TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből.",
|
||||
"CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.",
|
||||
"CleanupUserDataTask": "Felhasználói adatok tisztítása feladat"
|
||||
"CleanupUserDataTask": "Felhasználói adatok tisztítása feladat",
|
||||
"Original": "Eredeti"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Estrae o ottiene segmenti multimediali dai plugin abilitati MediaSegment.",
|
||||
"TaskExtractMediaSegments": "Scansiona Segmento Media",
|
||||
"CleanupUserDataTask": "Task di pulizia dei dati utente",
|
||||
"CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni."
|
||||
"CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.",
|
||||
"Original": "Originale"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskDownloadMissingLyrics": "Lejupielādēt trūkstošos vārdus",
|
||||
"TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām",
|
||||
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
|
||||
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas."
|
||||
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.",
|
||||
"Original": "Oriģināls"
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"Genres": "विधाहरू",
|
||||
"Folders": "फोल्डरहरू",
|
||||
"Favorites": "मनपर्ने",
|
||||
"FailedLoginAttemptWithUserName": "{0}को लग इन प्रयास असफल",
|
||||
"FailedLoginAttemptWithUserName": "असफल लग इन प्रयास {0} देखि",
|
||||
"DeviceOnlineWithName": "{0}को साथ जडित",
|
||||
"DeviceOfflineWithName": "{0}बाट विच्छेदन भयो",
|
||||
"Collections": "संग्रह",
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig zijn.",
|
||||
"CleanupUserDataTask": "Opruimtaak gebruikersdata",
|
||||
"Albums": "Albums",
|
||||
"Genres": "Genres"
|
||||
"Genres": "Genres",
|
||||
"Original": "Oorspronkelijk"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.",
|
||||
"TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki.",
|
||||
"CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.",
|
||||
"CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika"
|
||||
"CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika",
|
||||
"Original": "Oryginalny"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
|
||||
"CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
|
||||
"CleanupUserDataTask": "Limpeza de dados de utilizador"
|
||||
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
|
||||
"CleanupUserDataTask": "Task de limpeza de dados do usuário",
|
||||
"CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias."
|
||||
"CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder",
|
||||
"TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar.",
|
||||
"CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.",
|
||||
"CleanupUserDataTask": "Uppgift för rensning av användardata"
|
||||
"CleanupUserDataTask": "Uppgift för rensning av användardata",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -320,6 +320,14 @@ namespace Emby.Server.Implementations.Localization
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (ratingsDictionary is not null && rating.Length > countryCode.Length
|
||||
&& rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase)
|
||||
&& (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':')
|
||||
&& ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue))
|
||||
{
|
||||
return normalizedValue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -345,35 +353,70 @@ namespace Emby.Server.Implementations.Localization
|
||||
}
|
||||
}
|
||||
|
||||
// Try splitting by : to handle "Germany: FSK-18"
|
||||
if (rating.Contains(':', StringComparison.OrdinalIgnoreCase))
|
||||
// Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18"
|
||||
if (TryGetRatingScoreBySeparator(rating, ':', out var result)
|
||||
|| TryGetRatingScoreBySeparator(rating, '-', out result))
|
||||
{
|
||||
var ratingLevelRightPart = rating.AsSpan().RightPart(':');
|
||||
if (ratingLevelRightPart.Length != 0)
|
||||
{
|
||||
return GetRatingScore(ratingLevelRightPart.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle prefix country code to handle "DE-18"
|
||||
if (rating.Contains('-', StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ratingSpan = rating.AsSpan();
|
||||
|
||||
// Extract culture from country prefix
|
||||
var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString());
|
||||
|
||||
var ratingLevelRightPart = ratingSpan.RightPart('-');
|
||||
if (ratingLevelRightPart.Length != 0)
|
||||
{
|
||||
// Check rating system of culture
|
||||
return GetRatingScore(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result)
|
||||
{
|
||||
result = null;
|
||||
|
||||
if (rating.IndexOf(separator, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ratingSpan = rating.AsSpan();
|
||||
var countryPart = ratingSpan.LeftPart(separator).Trim().ToString();
|
||||
var ratingPart = ratingSpan.RightPart(separator).Trim().ToString();
|
||||
if (ratingPart.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? resolvedCountryCode = null;
|
||||
|
||||
if (_allParentalRatings.ContainsKey(countryPart))
|
||||
{
|
||||
resolvedCountryCode = countryPart;
|
||||
}
|
||||
else
|
||||
{
|
||||
var culture = FindLanguageInfo(countryPart);
|
||||
if (culture is not null)
|
||||
{
|
||||
resolvedCountryCode = culture.TwoLetterISOLanguageName;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedCountryCode is not null
|
||||
&& _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings))
|
||||
{
|
||||
if (countryRatings.TryGetValue(ratingPart, out result))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
|
||||
rating,
|
||||
resolvedCountryCode);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Country not identified or no rating data available, try recursive lookup
|
||||
result = GetRatingScore(ratingPart, resolvedCountryCode);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetLocalizedString(string phrase)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"supportsSubScores": true,
|
||||
"ratings": [
|
||||
{
|
||||
"ratingStrings": ["E", "G", "TV-Y", "TV-G"],
|
||||
"ratingStrings": ["C", "E", "G", "TV-Y", "TV-G"],
|
||||
"ratingScore": {
|
||||
"score": 0,
|
||||
"subScore": 0
|
||||
@@ -23,11 +23,18 @@
|
||||
"subScore": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["C8"],
|
||||
"ratingScore": {
|
||||
"score": 8,
|
||||
"subScore": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["PG", "TV-PG"],
|
||||
"ratingScore": {
|
||||
"score": 9,
|
||||
"subScore": 0
|
||||
"score": 8,
|
||||
"subScore": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -38,7 +45,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["TV-14"],
|
||||
"ratingStrings": ["14+", "TV-14"],
|
||||
"ratingScore": {
|
||||
"score": 14,
|
||||
"subScore": 1
|
||||
|
||||
@@ -85,9 +85,17 @@ namespace Emby.Server.Implementations.Serialization
|
||||
/// <returns>System.Object.</returns>
|
||||
public object? DeserializeFromFile(Type type, string file)
|
||||
{
|
||||
using (var stream = File.OpenRead(file))
|
||||
try
|
||||
{
|
||||
return DeserializeFromStream(type, stream);
|
||||
using (var stream = File.OpenRead(file))
|
||||
{
|
||||
return DeserializeFromStream(type, stream);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ex.Data.Add("Filename", file);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -973,7 +973,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (user.RememberAudioSelections)
|
||||
{
|
||||
if (data.AudioStreamIndex != info.AudioStreamIndex)
|
||||
if (info.AudioStreamIndex.HasValue && data.AudioStreamIndex != info.AudioStreamIndex)
|
||||
{
|
||||
data.AudioStreamIndex = info.AudioStreamIndex;
|
||||
changed = true;
|
||||
@@ -990,7 +990,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (user.RememberSubtitleSelections)
|
||||
{
|
||||
if (data.SubtitleStreamIndex != info.SubtitleStreamIndex)
|
||||
if (info.SubtitleStreamIndex.HasValue && data.SubtitleStreamIndex != info.SubtitleStreamIndex)
|
||||
{
|
||||
data.SubtitleStreamIndex = info.SubtitleStreamIndex;
|
||||
changed = true;
|
||||
@@ -1021,15 +1021,22 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
|
||||
if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(info), "The PlaybackStopInfo's PositionTicks was negative.");
|
||||
}
|
||||
|
||||
var session = GetSession(info.SessionId);
|
||||
|
||||
session.StopAutomaticProgress();
|
||||
|
||||
if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0)
|
||||
{
|
||||
// Ensure live stream is cleaned up before throwing, to prevent tuner
|
||||
// resource leaks when stalled clients report a negative PositionTicks.
|
||||
if (!string.IsNullOrEmpty(info.LiveStreamId))
|
||||
{
|
||||
await CloseLiveStreamIfNeededAsync(info.LiveStreamId, session.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(info), "The PlaybackStopInfo's PositionTicks was negative.");
|
||||
}
|
||||
|
||||
var libraryItem = info.ItemId.IsEmpty()
|
||||
? null
|
||||
: GetNowPlayingItem(session, info.ItemId);
|
||||
@@ -2049,7 +2056,7 @@ namespace Emby.Server.Implementations.Session
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var adminUserIds = _userManager.Users
|
||||
var adminUserIds = _userManager.GetUsers()
|
||||
.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
|
||||
.Select(i => i.Id)
|
||||
.ToList();
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers;
|
||||
/// The dashboard controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Tags("Plugin")]
|
||||
public class DashboardController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILogger<DashboardController> _logger;
|
||||
|
||||
@@ -196,6 +196,7 @@ public class InstantMixController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
|
||||
[HttpGet("MusicGenres/{name}/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Use GetInstantMixFromItem")]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName(
|
||||
[FromRoute, Required] string name,
|
||||
[FromQuery] Guid? userId,
|
||||
@@ -359,7 +360,7 @@ public class InstantMixController : BaseJellyfinApiController
|
||||
[HttpGet("MusicGenres/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Obsolete("Use GetInstantMixFromMusicGenreByName")]
|
||||
[Obsolete("Use GetInstantMixFromItem")]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById(
|
||||
[FromQuery, Required] Guid id,
|
||||
[FromQuery] Guid? userId,
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers;
|
||||
/// </summary>
|
||||
[Route("Items")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[Tags("Library")]
|
||||
public class ItemRefreshController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
@@ -242,6 +242,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
item.ForcedSortName = request.ForcedSortName;
|
||||
|
||||
item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle;
|
||||
item.OriginalLanguage = string.IsNullOrWhiteSpace(request.OriginalLanguage) ? null : request.OriginalLanguage;
|
||||
|
||||
item.CriticRating = request.CriticRating;
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace Jellyfin.Api.Controllers;
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
[Tags("Item")]
|
||||
[Tags("Library")]
|
||||
public class ItemsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
@@ -955,6 +955,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
[HttpGet("UserItems/{itemId}/UserData")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Tags("UserData")]
|
||||
public ActionResult<UserItemDataDto?> GetItemUserData(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
@@ -1010,6 +1011,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
[HttpPost("UserItems/{itemId}/UserData")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Tags("UserData")]
|
||||
public ActionResult<UserItemDataDto?> UpdateItemUserData(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Mime;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -18,8 +17,6 @@ using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
@@ -49,12 +46,11 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
private readonly IListingsManager _listingsManager;
|
||||
private readonly IRecordingsManager _recordingsManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IConfigurationManager _configurationManager;
|
||||
private readonly ITranscodeManager _transcodeManager;
|
||||
private readonly ISchedulesDirectService _schedulesDirectService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LiveTvController"/> class.
|
||||
@@ -65,12 +61,11 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <param name="listingsManager">Instance of the <see cref="IListingsManager"/> interface.</param>
|
||||
/// <param name="recordingsManager">Instance of the <see cref="IRecordingsManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
||||
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
|
||||
/// <param name="schedulesDirectService">Instance of the <see cref="ISchedulesDirectService"/> interface.</param>
|
||||
public LiveTvController(
|
||||
ILiveTvManager liveTvManager,
|
||||
IGuideManager guideManager,
|
||||
@@ -78,12 +73,11 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
IListingsManager listingsManager,
|
||||
IRecordingsManager recordingsManager,
|
||||
IUserManager userManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IConfigurationManager configurationManager,
|
||||
ITranscodeManager transcodeManager)
|
||||
ITranscodeManager transcodeManager,
|
||||
ISchedulesDirectService schedulesDirectService)
|
||||
{
|
||||
_liveTvManager = liveTvManager;
|
||||
_guideManager = guideManager;
|
||||
@@ -91,12 +85,11 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
_listingsManager = listingsManager;
|
||||
_recordingsManager = recordingsManager;
|
||||
_userManager = userManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_configurationManager = configurationManager;
|
||||
_transcodeManager = transcodeManager;
|
||||
_schedulesDirectService = schedulesDirectService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -345,20 +338,6 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
||||
[Obsolete("This endpoint is obsolete.")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries(
|
||||
[FromQuery] string? channelId,
|
||||
[FromQuery] Guid? userId,
|
||||
@@ -389,7 +368,6 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
||||
[Obsolete("This endpoint is obsolete.")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId)
|
||||
{
|
||||
return new QueryResult<BaseItemDto>();
|
||||
@@ -834,7 +812,6 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[HttpPost("Timers/{timerId}")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
|
||||
public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo)
|
||||
{
|
||||
await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -924,7 +901,6 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[HttpPost("SeriesTimers/{timerId}")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
|
||||
public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
|
||||
{
|
||||
await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -980,9 +956,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
||||
{
|
||||
var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
|
||||
config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
_configurationManager.SaveConfiguration("livetv", config);
|
||||
_tunerHostManager.DeleteTunerHost(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -1073,13 +1047,8 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[ProducesFile(MediaTypeNames.Application.Json)]
|
||||
public async Task<ActionResult> GetSchedulesDirectCountries()
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
// https://json.schedulesdirect.org/20141201/available/countries
|
||||
// Can't dispose the response as it's required up the call chain.
|
||||
var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries"))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json);
|
||||
var stream = await _schedulesDirectService.GetAvailableCountries(CancellationToken.None).ConfigureAwait(false);
|
||||
return File(stream, MediaTypeNames.Application.Json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -72,6 +72,7 @@ public class PlaystateController : BaseJellyfinApiController
|
||||
[HttpPost("UserPlayedItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Tags("UserData")]
|
||||
public async Task<ActionResult<UserItemDataDto?>> MarkPlayedItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
@@ -138,6 +139,7 @@ public class PlaystateController : BaseJellyfinApiController
|
||||
[HttpDelete("UserPlayedItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Tags("UserData")]
|
||||
public async Task<ActionResult<UserItemDataDto?>> MarkUnplayedItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
|
||||
@@ -432,6 +432,7 @@ public class SessionController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns>
|
||||
[HttpGet("Auth/Providers")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[Tags("Authentication")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders()
|
||||
{
|
||||
@@ -444,6 +445,7 @@ public class SessionController : BaseJellyfinApiController
|
||||
/// <response code="200">Password reset providers retrieved.</response>
|
||||
/// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns>
|
||||
[HttpGet("Auth/PasswordResetProviders")]
|
||||
[Tags("Authentication")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Models.StartupDtos;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Net;
|
||||
@@ -54,6 +53,7 @@ public class StartupController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns>
|
||||
[HttpGet("Configuration")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Use configuration endpoints")]
|
||||
public ActionResult<StartupConfigurationDto> GetStartupConfiguration()
|
||||
{
|
||||
return new StartupConfigurationDto
|
||||
@@ -73,6 +73,7 @@ public class StartupController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Configuration")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Obsolete("Use configuration endpoints")]
|
||||
public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
|
||||
{
|
||||
_config.Configuration.ServerName = startupConfiguration.ServerName ?? string.Empty;
|
||||
@@ -91,6 +92,7 @@ public class StartupController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("RemoteAccess")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Obsolete("Use configuration endpoints")]
|
||||
public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
|
||||
{
|
||||
NetworkConfiguration settings = _config.GetNetworkConfiguration();
|
||||
@@ -107,11 +109,12 @@ public class StartupController : BaseJellyfinApiController
|
||||
[HttpGet("User")]
|
||||
[HttpGet("FirstUser", Name = "GetFirstUser_2")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Use authentication endpoints")]
|
||||
public async Task<StartupUserDto> GetFirstUser()
|
||||
{
|
||||
// TODO: Remove this method when startup wizard no longer requires an existing user.
|
||||
await _userManager.InitializeAsync().ConfigureAwait(false);
|
||||
var user = _userManager.Users.First();
|
||||
var user = _userManager.GetFirstUser() ?? throw new InvalidOperationException("No user exists after initialization.");
|
||||
return new StartupUserDto
|
||||
{
|
||||
Name = user.Username
|
||||
@@ -131,7 +134,12 @@ public class StartupController : BaseJellyfinApiController
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
|
||||
{
|
||||
var user = _userManager.Users.First();
|
||||
var user = _userManager.GetFirstUser();
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
|
||||
{
|
||||
return BadRequest("Password must not be empty");
|
||||
@@ -146,7 +154,7 @@ public class StartupController : BaseJellyfinApiController
|
||||
|
||||
if (!string.IsNullOrEmpty(startupUserDto.Password))
|
||||
{
|
||||
await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
|
||||
await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
|
||||
@@ -208,6 +208,7 @@ public class UserController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
|
||||
[HttpPost("AuthenticateByName")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Tags("Authentication")]
|
||||
public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request)
|
||||
{
|
||||
var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
|
||||
@@ -243,6 +244,7 @@ public class UserController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
|
||||
[HttpPost("AuthenticateWithQuickConnect")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Tags("Authentication")]
|
||||
public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
|
||||
{
|
||||
try
|
||||
@@ -288,7 +290,7 @@ public class UserController : BaseJellyfinApiController
|
||||
|
||||
if (request.ResetPassword)
|
||||
{
|
||||
await _userManager.ResetPassword(user).ConfigureAwait(false);
|
||||
await _userManager.ResetPassword(user.Id).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -306,7 +308,7 @@ public class UserController : BaseJellyfinApiController
|
||||
}
|
||||
}
|
||||
|
||||
await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false);
|
||||
await _userManager.ChangePassword(user.Id, request.NewPw ?? string.Empty).ConfigureAwait(false);
|
||||
|
||||
var currentToken = User.GetToken();
|
||||
|
||||
@@ -369,7 +371,7 @@ public class UserController : BaseJellyfinApiController
|
||||
|
||||
if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
|
||||
{
|
||||
await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
|
||||
await _userManager.RenameUser(user.Id, user.Username, updateUser.Name).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false);
|
||||
@@ -425,7 +427,7 @@ public class UserController : BaseJellyfinApiController
|
||||
// If removing admin access
|
||||
if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
|
||||
{
|
||||
if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
||||
if (_userManager.GetUsers().Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
|
||||
}
|
||||
@@ -440,7 +442,7 @@ public class UserController : BaseJellyfinApiController
|
||||
// If disabling
|
||||
if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
|
||||
{
|
||||
if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
|
||||
if (_userManager.GetUsers().Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
|
||||
}
|
||||
@@ -522,7 +524,7 @@ public class UserController : BaseJellyfinApiController
|
||||
// no need to authenticate password for new user
|
||||
if (request.Password is not null)
|
||||
{
|
||||
await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
|
||||
await _userManager.ChangePassword(newUser.Id, request.Password).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString());
|
||||
@@ -538,6 +540,7 @@ public class UserController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns>
|
||||
[HttpPost("ForgotPassword")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Tags("Authentication")]
|
||||
public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest)
|
||||
{
|
||||
var ip = HttpContext.GetNormalizedRemoteIP();
|
||||
@@ -562,6 +565,7 @@ public class UserController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns>
|
||||
[HttpPost("ForgotPassword/Pin")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Tags("Authentication")]
|
||||
public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest)
|
||||
{
|
||||
var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false);
|
||||
@@ -597,7 +601,7 @@ public class UserController : BaseJellyfinApiController
|
||||
|
||||
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
|
||||
{
|
||||
var users = _userManager.Users;
|
||||
var users = _userManager.GetUsers();
|
||||
|
||||
if (isDisabled.HasValue)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace Jellyfin.Api.Controllers;
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
[Tags("Library")]
|
||||
public class UserLibraryController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
@@ -212,6 +213,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpPost("UserFavoriteItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Tags("UserData")]
|
||||
public ActionResult<UserItemDataDto> MarkFavoriteItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
@@ -259,6 +261,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpDelete("UserFavoriteItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Tags("UserData")]
|
||||
public ActionResult<UserItemDataDto> UnmarkFavoriteItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
@@ -306,6 +309,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpDelete("UserItems/{itemId}/Rating")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Tags("UserData")]
|
||||
public ActionResult<UserItemDataDto?> DeleteUserItemRating(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
@@ -354,6 +358,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpPost("UserItems/{itemId}/Rating")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Tags("UserData")]
|
||||
public ActionResult<UserItemDataDto?> UpdateUserItemRating(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Data</PackageId>
|
||||
<VersionPrefix>10.12.0</VersionPrefix>
|
||||
<VersionPrefix>12.0.0</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -68,6 +68,7 @@ internal static class BaseItemMapper
|
||||
dto.CriticRating = entity.CriticRating;
|
||||
dto.PresentationUniqueKey = entity.PresentationUniqueKey;
|
||||
dto.OriginalTitle = entity.OriginalTitle;
|
||||
dto.OriginalLanguage = entity.OriginalLanguage;
|
||||
dto.Album = entity.Album;
|
||||
dto.LUFS = entity.LUFS;
|
||||
dto.NormalizationGain = entity.NormalizationGain;
|
||||
@@ -243,6 +244,7 @@ internal static class BaseItemMapper
|
||||
entity.CriticRating = dto.CriticRating;
|
||||
entity.PresentationUniqueKey = dto.PresentationUniqueKey;
|
||||
entity.OriginalTitle = dto.OriginalTitle;
|
||||
entity.OriginalLanguage = dto.OriginalLanguage;
|
||||
entity.Album = dto.Album;
|
||||
entity.LUFS = dto.LUFS;
|
||||
entity.NormalizationGain = dto.NormalizationGain;
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Querying;
|
||||
@@ -170,92 +169,40 @@ public sealed partial class BaseItemRepository
|
||||
ExcludeItemIds = filter.ExcludeItemIds
|
||||
};
|
||||
|
||||
// Build the master query and collapse rows that share a PresentationUniqueKey
|
||||
// (e.g. alternate versions) by picking the lowest Id per group.
|
||||
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking
|
||||
// the lowest Id per group. Keep as an IQueryable sub-select so paging is applied AFTER
|
||||
// ApplyOrder runs the caller's actual sort.
|
||||
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
|
||||
|
||||
var orderedMasterQuery = ApplyOrder(masterQuery, filter, context)
|
||||
var representativeIds = masterQuery
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g.Min(e => e.Id));
|
||||
|
||||
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
result.TotalRecordCount = orderedMasterQuery.Count();
|
||||
result.TotalRecordCount = representativeIds.Count();
|
||||
}
|
||||
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
|
||||
{
|
||||
orderedMasterQuery = orderedMasterQuery.Skip(filter.StartIndex.Value);
|
||||
}
|
||||
|
||||
if (filter.Limit.HasValue)
|
||||
{
|
||||
orderedMasterQuery = orderedMasterQuery.Take(filter.Limit.Value);
|
||||
}
|
||||
|
||||
var masterIds = orderedMasterQuery.ToList();
|
||||
|
||||
var query = ApplyNavigations(
|
||||
context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => masterIds.Contains(e.Id)),
|
||||
context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)),
|
||||
filter);
|
||||
|
||||
query = ApplyOrder(query, filter, context);
|
||||
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
|
||||
{
|
||||
query = query.Skip(filter.StartIndex.Value);
|
||||
}
|
||||
|
||||
if (filter.Limit.HasValue)
|
||||
{
|
||||
query = query.Take(filter.Limit.Value);
|
||||
}
|
||||
|
||||
result.StartIndex = filter.StartIndex ?? 0;
|
||||
if (filter.IncludeItemTypes.Length > 0)
|
||||
{
|
||||
var typeSubQuery = new InternalItemsQuery(filter.User)
|
||||
{
|
||||
ExcludeItemTypes = filter.ExcludeItemTypes,
|
||||
IncludeItemTypes = filter.IncludeItemTypes,
|
||||
MediaTypes = filter.MediaTypes,
|
||||
AncestorIds = filter.AncestorIds,
|
||||
ExcludeItemIds = filter.ExcludeItemIds,
|
||||
ItemIds = filter.ItemIds,
|
||||
TopParentIds = filter.TopParentIds,
|
||||
ParentId = filter.ParentId,
|
||||
IsPlayed = filter.IsPlayed
|
||||
};
|
||||
|
||||
var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
|
||||
.Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
|
||||
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
|
||||
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
|
||||
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
|
||||
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
|
||||
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
|
||||
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
|
||||
var itemIds = itemCountQuery.Select(e => e.Id);
|
||||
|
||||
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
|
||||
// Instead, start from ItemValueMaps and join with BaseItems
|
||||
var countsByCleanName = context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.Where(ivm => itemIds.Contains(ivm.ItemId))
|
||||
.Join(
|
||||
context.BaseItems,
|
||||
ivm => ivm.ItemId,
|
||||
e => e.Id,
|
||||
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
|
||||
.GroupBy(x => new { x.CleanName, x.Type })
|
||||
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
|
||||
.GroupBy(x => x.CleanName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new ItemCounts
|
||||
{
|
||||
SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
|
||||
EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
|
||||
MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
|
||||
AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
|
||||
ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
|
||||
SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
|
||||
TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
|
||||
});
|
||||
|
||||
result.StartIndex = filter.StartIndex ?? 0;
|
||||
var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes);
|
||||
result.Items =
|
||||
[
|
||||
.. query
|
||||
@@ -273,7 +220,6 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
else
|
||||
{
|
||||
result.StartIndex = filter.StartIndex ?? 0;
|
||||
result.Items =
|
||||
[
|
||||
.. query
|
||||
@@ -287,4 +233,61 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Dictionary<string, ItemCounts> BuildItemCountsByCleanName(
|
||||
Database.Implementations.JellyfinDbContext context,
|
||||
InternalItemsQuery filter,
|
||||
IReadOnlyList<ItemValueType> itemValueTypes)
|
||||
{
|
||||
var typeSubQuery = new InternalItemsQuery(filter.User)
|
||||
{
|
||||
ExcludeItemTypes = filter.ExcludeItemTypes,
|
||||
IncludeItemTypes = filter.IncludeItemTypes,
|
||||
MediaTypes = filter.MediaTypes,
|
||||
AncestorIds = filter.AncestorIds,
|
||||
ExcludeItemIds = filter.ExcludeItemIds,
|
||||
ItemIds = filter.ItemIds,
|
||||
TopParentIds = filter.TopParentIds,
|
||||
ParentId = filter.ParentId,
|
||||
IsPlayed = filter.IsPlayed
|
||||
};
|
||||
|
||||
var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
|
||||
.Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
|
||||
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
|
||||
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
|
||||
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
|
||||
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
|
||||
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
|
||||
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
|
||||
var itemIds = itemCountQuery.Select(e => e.Id);
|
||||
|
||||
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
|
||||
// Instead, start from ItemValueMaps and join with BaseItems
|
||||
return context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.Where(ivm => itemIds.Contains(ivm.ItemId))
|
||||
.Join(
|
||||
context.BaseItems,
|
||||
ivm => ivm.ItemId,
|
||||
e => e.Id,
|
||||
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
|
||||
.GroupBy(x => new { x.CleanName, x.Type })
|
||||
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
|
||||
.GroupBy(x => x.CleanName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new ItemCounts
|
||||
{
|
||||
SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
|
||||
EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
|
||||
MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
|
||||
AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
|
||||
ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
|
||||
SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
|
||||
TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
@@ -125,45 +124,53 @@ public sealed partial class BaseItemRepository
|
||||
return GetLatestTvShowItems(context, baseQuery, filter, limit);
|
||||
}
|
||||
|
||||
// Find the top N group keys ordered by most recent DateCreated.
|
||||
// Movies group by PresentationUniqueKey (alternate versions like 4K/1080p share a key).
|
||||
// Music groups by Album.
|
||||
Expression<Func<BaseItemEntity, bool>> groupKeyFilter;
|
||||
Expression<Func<BaseItemEntity, string?>> groupKeySelector;
|
||||
|
||||
if (collectionType is CollectionType.movies)
|
||||
{
|
||||
groupKeyFilter = e => e.PresentationUniqueKey != null;
|
||||
groupKeySelector = e => e.PresentationUniqueKey;
|
||||
}
|
||||
else
|
||||
{
|
||||
groupKeyFilter = e => e.Album != null;
|
||||
groupKeySelector = e => e.Album;
|
||||
// Group by PresentationUniqueKey, pick the newest item per group.
|
||||
var topGroupItems = baseQuery
|
||||
.Where(e => e.PresentationUniqueKey != null)
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => new
|
||||
{
|
||||
MaxDate = g.Max(e => e.DateCreated),
|
||||
FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
|
||||
})
|
||||
.OrderByDescending(g => g.MaxDate);
|
||||
|
||||
var firstIdsQuery = filter.Limit.HasValue
|
||||
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
|
||||
: topGroupItems.Select(g => g.FirstId);
|
||||
|
||||
return LoadLatestByIds(context, firstIdsQuery, filter);
|
||||
}
|
||||
|
||||
// Group by GroupKey, pick the latest item per group (correlated subquery: ORDER BY DateCreated DESC, Id DESC LIMIT 1),
|
||||
// order groups by group max date, take the top N — all in a single SQL statement.
|
||||
// ThenByDescending(Id) is the tiebreaker for deterministic ordering when items share a DateCreated.
|
||||
var topGroupItems = baseQuery
|
||||
.Where(groupKeyFilter)
|
||||
.GroupBy(groupKeySelector)
|
||||
.Select(g => new
|
||||
{
|
||||
MaxDate = g.Max(e => e.DateCreated),
|
||||
FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
|
||||
})
|
||||
.OrderByDescending(g => g.MaxDate);
|
||||
// Albums whose Id is the parent of any track matching the user's filter.
|
||||
var albumIdsWithMatchingTrack = context.AncestorIds
|
||||
.Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
|
||||
|
||||
var firstIdsQuery = filter.Limit.HasValue
|
||||
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
|
||||
: topGroupItems.Select(g => g.FirstId);
|
||||
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
|
||||
var topAlbumsQuery = context.BaseItems.AsNoTracking()
|
||||
.Where(album => album.Type == musicAlbumTypeName)
|
||||
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id))
|
||||
.OrderByDescending(album => album.DateCreated)
|
||||
.ThenByDescending(album => album.Id);
|
||||
|
||||
var firstIds = firstIdsQuery.ToList();
|
||||
var albumIdsQuery = filter.Limit.HasValue
|
||||
? topAlbumsQuery.Take(filter.Limit.Value).Select(a => a.Id)
|
||||
: topAlbumsQuery.Select(a => a.Id);
|
||||
|
||||
// Single bound JSON / array parameter via WhereOneOrMany — keeps SQL small regardless of N.
|
||||
var itemsQuery = context.BaseItems.AsNoTracking().WhereOneOrMany(firstIds, e => e.Id);
|
||||
itemsQuery = ApplyNavigations(itemsQuery, filter);
|
||||
return LoadLatestByIds(context, albumIdsQuery, filter);
|
||||
}
|
||||
|
||||
// Keeping idsQuery deferred lets EF emit `WHERE Id IN (<subquery>)`.
|
||||
private IReadOnlyList<BaseItemDto> LoadLatestByIds(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<Guid> idsQuery,
|
||||
InternalItemsQuery filter)
|
||||
{
|
||||
var itemsQuery = ApplyNavigations(
|
||||
context.BaseItems.AsNoTracking().Where(e => idsQuery.Contains(e.Id)),
|
||||
filter);
|
||||
|
||||
return itemsQuery
|
||||
.OrderByDescending(e => e.DateCreated)
|
||||
|
||||
@@ -390,7 +390,8 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
if (filter.UseRawName == true)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Name == filter.Name);
|
||||
var nameLower = filter.Name.ToLowerInvariant();
|
||||
baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
#pragma warning disable CA1304 // Specify CultureInfo
|
||||
#pragma warning disable CA1311 // Specify a culture or use an invariant version
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -62,17 +64,19 @@ public class LinkedChildrenService : ILinkedChildrenService
|
||||
{
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
|
||||
var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray();
|
||||
var artists = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
|
||||
.Where(e => artistNames.Contains(e.Name))
|
||||
.Where(e => lowerNames.Contains(e.Name!.ToLower()))
|
||||
.ToArray();
|
||||
|
||||
var lookup = artists
|
||||
.GroupBy(e => e.Name!)
|
||||
.GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||
foreach (var name in artistNames)
|
||||
|
||||
@@ -123,6 +123,7 @@ public class MediaStreamRepository : IMediaStreamRepository
|
||||
dto.IsDefault = entity.IsDefault;
|
||||
dto.IsForced = entity.IsForced;
|
||||
dto.IsExternal = entity.IsExternal;
|
||||
dto.IsOriginal = entity.IsOriginal;
|
||||
dto.Height = entity.Height;
|
||||
dto.Width = entity.Width;
|
||||
dto.AverageFrameRate = entity.AverageFrameRate;
|
||||
@@ -164,6 +165,11 @@ public class MediaStreamRepository : IMediaStreamRepository
|
||||
dto.LocalizedLanguage = culture?.DisplayName;
|
||||
}
|
||||
|
||||
if (dto.Type is MediaStreamType.Audio)
|
||||
{
|
||||
dto.LocalizedOriginal = _localization.GetLocalizedString("Original");
|
||||
}
|
||||
|
||||
if (dto.Type is MediaStreamType.Subtitle)
|
||||
{
|
||||
dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined");
|
||||
@@ -198,6 +204,7 @@ public class MediaStreamRepository : IMediaStreamRepository
|
||||
IsDefault = dto.IsDefault,
|
||||
IsForced = dto.IsForced,
|
||||
IsExternal = dto.IsExternal,
|
||||
IsOriginal = dto.IsOriginal,
|
||||
Height = dto.Height,
|
||||
Width = dto.Width,
|
||||
AverageFrameRate = dto.AverageFrameRate,
|
||||
|
||||
@@ -127,15 +127,21 @@ public class NextUpService : INextUpService
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Type == episodeTypeName)
|
||||
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
|
||||
.Where(e => e.ParentIndexNumber != 0)
|
||||
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
|
||||
.Where(e => e.ParentIndexNumber != 0);
|
||||
lastWatchedByDateBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedByDateBase, filter);
|
||||
|
||||
// Use lightweight projection + client-side grouping instead of
|
||||
// SelectMany+GroupBy+OrderByDescending+FirstOrDefault (correlated subquery).
|
||||
// Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation.
|
||||
// SelectMany on UserData with a correlated Where would translate to APPLY,
|
||||
// which SQLite does not support.
|
||||
var playedWithDates = lastWatchedByDateBase
|
||||
.SelectMany(e => e.UserData!.Where(ud => ud.UserId == userId && ud.Played)
|
||||
.Select(ud => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate }))
|
||||
.Join(
|
||||
context.UserData
|
||||
.AsNoTracking()
|
||||
.Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
|
||||
.Where(ud => ud.Played),
|
||||
e => new { UserId = userId, ItemId = e.Id },
|
||||
ud => new { ud.UserId, ud.ItemId },
|
||||
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
|
||||
.ToList();
|
||||
|
||||
foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey))
|
||||
|
||||
@@ -48,9 +48,9 @@ public static class OrderMapper
|
||||
(ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
|
||||
(ItemSortBy.Album, _) => e => e.Album,
|
||||
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
|
||||
(ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
|
||||
(ItemSortBy.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null),
|
||||
(ItemSortBy.StartDate, _) => e => e.StartDate,
|
||||
(ItemSortBy.Name, _) => e => e.CleanName,
|
||||
(ItemSortBy.Name, _) => e => e.SortName,
|
||||
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
|
||||
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
|
||||
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
|
||||
|
||||
@@ -44,7 +44,16 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
}
|
||||
else
|
||||
{
|
||||
dbQuery = dbQuery.OrderBy(e => e.Name);
|
||||
// The Peoples table has one row per (Name, PersonType), so the same person can
|
||||
// appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
|
||||
// name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the
|
||||
// grouping key so case-only duplicates collapse together.
|
||||
var representativeIds = dbQuery
|
||||
.GroupBy(e => e.Name.ToLower())
|
||||
.Select(g => g.Min(e => e.Id));
|
||||
dbQuery = context.Peoples.AsNoTracking()
|
||||
.Where(p => representativeIds.Contains(p.Id))
|
||||
.OrderBy(e => e.Name);
|
||||
}
|
||||
|
||||
var count = dbQuery.Count();
|
||||
@@ -94,16 +103,16 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
person.Role = person.Role?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
// multiple metadata providers can provide the _same_ person
|
||||
people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray();
|
||||
var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray();
|
||||
// multiple metadata providers can provide the _same_ person; dedupe case-insensitively.
|
||||
people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
|
||||
var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
var existingPersons = context.Peoples.Select(e => new
|
||||
{
|
||||
item = e,
|
||||
SelectionKey = e.Name + "-" + e.PersonType
|
||||
SelectionKey = e.Name.ToLower() + "-" + e.PersonType
|
||||
})
|
||||
.Where(p => personKeys.Contains(p.SelectionKey))
|
||||
.Select(f => f.item)
|
||||
@@ -111,7 +120,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
|
||||
var toAdd = people
|
||||
.Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
|
||||
.Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
|
||||
.Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString()))
|
||||
.Select(Map);
|
||||
context.Peoples.AddRange(toAdd);
|
||||
context.SaveChanges();
|
||||
@@ -129,8 +138,8 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
continue;
|
||||
}
|
||||
|
||||
var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString());
|
||||
var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
|
||||
var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString());
|
||||
var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
|
||||
if (existingMap is null)
|
||||
{
|
||||
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
|
||||
@@ -231,7 +240,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
|
||||
if (queryExcludePersonTypes.Count > 0)
|
||||
{
|
||||
query = query.Where(e => !queryPersonTypes.Contains(e.PersonType));
|
||||
query = query.Where(e => !queryExcludePersonTypes.Contains(e.PersonType));
|
||||
}
|
||||
|
||||
if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty())
|
||||
|
||||
@@ -54,7 +54,7 @@ public class MediaSegmentManager : IMediaSegmentManager
|
||||
public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken)
|
||||
{
|
||||
var providers = _segmentProviders
|
||||
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
|
||||
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(e.Name, StringComparer.OrdinalIgnoreCase))
|
||||
.OrderBy(i =>
|
||||
{
|
||||
var index = libraryOptions.MediaSegmentProviderOrder.IndexOf(i.Name);
|
||||
@@ -224,7 +224,7 @@ public class MediaSegmentManager : IMediaSegmentManager
|
||||
if (filterByProvider)
|
||||
{
|
||||
var providerIds = _segmentProviders
|
||||
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
|
||||
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(e.Name, StringComparer.OrdinalIgnoreCase))
|
||||
.Select(f => GetProviderId(f.Name))
|
||||
.ToArray();
|
||||
if (providerIds.Length == 0)
|
||||
|
||||
@@ -198,13 +198,13 @@ public class TrickplayManager : ITrickplayManager
|
||||
// Cleanup old trickplay files
|
||||
if (Directory.Exists(trickplayDirectory))
|
||||
{
|
||||
var existingFolders = Directory.GetDirectories(trickplayDirectory).ToList();
|
||||
var existingFolders = Directory.GetDirectories(trickplayDirectory);
|
||||
var trickplayInfos = await dbContext.TrickplayInfos
|
||||
.AsNoTracking()
|
||||
.Where(i => i.ItemId.Equals(video.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)).ToList();
|
||||
var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia));
|
||||
var foldersToRemove = existingFolders.Except(expectedFolders);
|
||||
foreach (var folder in foldersToRemove)
|
||||
{
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
var resetUser = userManager.GetUserByName(spr.UserName)
|
||||
?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found");
|
||||
|
||||
await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false);
|
||||
await userManager.ChangePassword(resetUser.Id, pin).ConfigureAwait(false);
|
||||
usersReset.Add(resetUser.Username);
|
||||
File.Delete(resetFile);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
#pragma warning disable CA1307
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data.Events;
|
||||
@@ -35,7 +37,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
/// <summary>
|
||||
/// Manages the creation and retrieval of <see cref="User"/> instances.
|
||||
/// </summary>
|
||||
public partial class UserManager : IUserManager
|
||||
public partial class UserManager : IUserManager, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IEventManager _eventManager;
|
||||
@@ -50,7 +52,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
private readonly IDictionary<Guid, User> _users;
|
||||
private readonly AsyncKeyedLocker<Guid> _userLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
||||
@@ -89,29 +91,28 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
|
||||
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
|
||||
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
|
||||
|
||||
_users = new ConcurrentDictionary<Guid, User>();
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
foreach (var user in dbContext.Users
|
||||
.AsSingleQuery()
|
||||
.Include(user => user.Permissions)
|
||||
.Include(user => user.Preferences)
|
||||
.Include(user => user.AccessSchedules)
|
||||
.Include(user => user.ProfileImage)
|
||||
.AsEnumerable())
|
||||
{
|
||||
_users.Add(user.Id, user);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<User> Users => _users.Values;
|
||||
public IEnumerable<User> GetUsers()
|
||||
{
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
return UserQuery(dbContext)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<Guid> UsersIds => _users.Keys;
|
||||
public IEnumerable<Guid> GetUsersIds()
|
||||
{
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
return dbContext.Users
|
||||
.AsNoTracking()
|
||||
.Select(user => user.Id)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// This is some regex that matches only on unicode "word" characters, as well as -, _ and @
|
||||
// In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
|
||||
@@ -127,8 +128,27 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
throw new ArgumentException("Guid can't be empty", nameof(id));
|
||||
}
|
||||
|
||||
_users.TryGetValue(id, out var user);
|
||||
return user;
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
return UserQuery(dbContext)
|
||||
.FirstOrDefault(user => user.Id == id);
|
||||
}
|
||||
|
||||
private static IQueryable<User> UserQuery(JellyfinDbContext dbContext)
|
||||
{
|
||||
return dbContext.Users
|
||||
.AsSingleQuery()
|
||||
.Include(user => user.Permissions)
|
||||
.Include(user => user.Preferences)
|
||||
.Include(user => user.AccessSchedules)
|
||||
.Include(user => user.ProfileImage)
|
||||
.AsNoTracking();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public User? GetFirstUser()
|
||||
{
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
return UserQuery(dbContext).FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -139,42 +159,57 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
throw new ArgumentException("Invalid username", nameof(name));
|
||||
}
|
||||
|
||||
return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
return UserQuery(dbContext)
|
||||
.FirstOrDefault(u => u.Username.ToUpper() == name.ToUpper());
|
||||
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RenameUser(User user, string newName)
|
||||
public async Task RenameUser(Guid userId, string oldName, string newName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
ThrowIfInvalidUsername(newName);
|
||||
|
||||
if (user.Username.Equals(newName, StringComparison.Ordinal))
|
||||
if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("The new and old names must be different.");
|
||||
}
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
User user = null!; // user is never actually null where its used afterwards so we can just ignore.
|
||||
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
if (await dbContext.Users
|
||||
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id))
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
throw new ArgumentException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"A user with the name '{0}' already exists.",
|
||||
newName));
|
||||
}
|
||||
if (await dbContext.Users
|
||||
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && u.Id != userId)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
throw new ArgumentException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"A user with the name '{0}' already exists.",
|
||||
newName));
|
||||
}
|
||||
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
|
||||
user.Username = newName;
|
||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
||||
user = await UserQuery(dbContext)
|
||||
.AsTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == userId)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new ResourceNotFoundException(nameof(userId));
|
||||
user.Username = newName;
|
||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var eventArgs = new UserUpdatedEventArgs(user);
|
||||
@@ -185,10 +220,9 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateUserAsync(User user)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||
{
|
||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,23 +252,30 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
{
|
||||
ThrowIfInvalidUsername(name);
|
||||
|
||||
if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new ArgumentException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"A user with the name '{0}' already exists.",
|
||||
name));
|
||||
}
|
||||
|
||||
User newUser;
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
if (await dbContext.Users
|
||||
.AnyAsync(u => u.Username.ToUpper() == name.ToUpper())
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
throw new ArgumentException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"A user with the name '{0}' already exists.",
|
||||
name));
|
||||
}
|
||||
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
|
||||
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
|
||||
|
||||
dbContext.Users.Add(newUser);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
_users.Add(newUser.Id, newUser);
|
||||
}
|
||||
|
||||
await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
|
||||
@@ -245,62 +286,82 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
/// <inheritdoc/>
|
||||
public async Task DeleteUserAsync(Guid userId)
|
||||
{
|
||||
if (!_users.TryGetValue(userId, out var user))
|
||||
User? user;
|
||||
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(userId));
|
||||
}
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
user = await dbContext.Users
|
||||
.Include(u => u.Permissions)
|
||||
.FirstOrDefaultAsync(u => u.Id.Equals(userId))
|
||||
.ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(userId));
|
||||
}
|
||||
|
||||
if (_users.Count == 1)
|
||||
{
|
||||
throw new InvalidOperationException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
|
||||
user.Username));
|
||||
}
|
||||
var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false);
|
||||
if (userCount == 1)
|
||||
{
|
||||
throw new InvalidOperationException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
|
||||
user.Username));
|
||||
}
|
||||
|
||||
if (user.HasPermission(PermissionKind.IsAdministrator)
|
||||
&& Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
|
||||
user.Username),
|
||||
nameof(userId));
|
||||
}
|
||||
if (user.HasPermission(PermissionKind.IsAdministrator)
|
||||
&& await dbContext.Users
|
||||
.CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value))
|
||||
.ConfigureAwait(false) == 1)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
|
||||
user.Username),
|
||||
nameof(userId));
|
||||
}
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
dbContext.Users.Attach(user);
|
||||
dbContext.Users.Remove(user);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
dbContext.Users.Remove(user);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
_users.Remove(userId);
|
||||
|
||||
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ResetPassword(User user)
|
||||
public Task ResetPassword(Guid userId)
|
||||
{
|
||||
return ChangePassword(user, string.Empty);
|
||||
return ChangePassword(userId, string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ChangePassword(User user, string newPassword)
|
||||
public async Task ChangePassword(Guid userId, string newPassword)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
|
||||
User dbUser = null!;
|
||||
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||
{
|
||||
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
dbUser = await UserQuery(dbContext)
|
||||
.AsTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == userId)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new ResourceNotFoundException(nameof(userId));
|
||||
if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
|
||||
{
|
||||
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
|
||||
}
|
||||
|
||||
await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
|
||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
||||
|
||||
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false);
|
||||
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -400,102 +461,114 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
throw new ArgumentNullException(nameof(username));
|
||||
}
|
||||
|
||||
var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
|
||||
var authResult = await AuthenticateLocalUser(username, password, user)
|
||||
.ConfigureAwait(false);
|
||||
var authenticationProvider = authResult.AuthenticationProvider;
|
||||
var success = authResult.Success;
|
||||
|
||||
if (user is null)
|
||||
bool success;
|
||||
var user = GetUserByName(username);
|
||||
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
|
||||
{
|
||||
string updatedUsername = authResult.Username;
|
||||
|
||||
if (success
|
||||
&& authenticationProvider is not null
|
||||
&& authenticationProvider is not DefaultAuthenticationProvider)
|
||||
// Reload the user now that we hold the lock so the RowVersion is current.
|
||||
// GetUserByName uses AsNoTracking and the snapshot may be stale if another
|
||||
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
|
||||
if (user is not null)
|
||||
{
|
||||
// Trust the username returned by the authentication provider
|
||||
username = updatedUsername;
|
||||
user = GetUserById(user.Id) ?? user;
|
||||
}
|
||||
|
||||
// Search the database for the user again
|
||||
// the authentication provider might have created it
|
||||
user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
|
||||
var authResult = await AuthenticateLocalUser(username, password, user)
|
||||
.ConfigureAwait(false);
|
||||
var authenticationProvider = authResult.AuthenticationProvider;
|
||||
success = authResult.Success;
|
||||
|
||||
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
||||
if (user is null)
|
||||
{
|
||||
string updatedUsername = authResult.Username;
|
||||
|
||||
if (success
|
||||
&& authenticationProvider is not null
|
||||
&& authenticationProvider is not DefaultAuthenticationProvider)
|
||||
{
|
||||
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
||||
// Trust the username returned by the authentication provider
|
||||
username = updatedUsername;
|
||||
|
||||
// Search the database for the user again
|
||||
// the authentication provider might have created it
|
||||
user = GetUserByName(username);
|
||||
|
||||
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
||||
{
|
||||
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (success && user is not null && authenticationProvider is not null)
|
||||
{
|
||||
var providerId = authenticationProvider.GetType().FullName;
|
||||
|
||||
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
||||
if (success && user is not null && authenticationProvider is not null)
|
||||
{
|
||||
user.AuthenticationProviderId = providerId;
|
||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
var providerId = authenticationProvider.GetType().FullName;
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||
username,
|
||||
remoteEndPoint);
|
||||
throw new AuthenticationException("Invalid username or password entered.");
|
||||
}
|
||||
|
||||
if (user.HasPermission(PermissionKind.IsDisabled))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).",
|
||||
username,
|
||||
remoteEndPoint);
|
||||
throw new SecurityException(
|
||||
$"The {user.Username} account is currently disabled. Please consult with your administrator.");
|
||||
}
|
||||
|
||||
if (!user.HasPermission(PermissionKind.EnableRemoteAccess) &&
|
||||
!_networkManager.IsInLocalNetwork(remoteEndPoint))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).",
|
||||
username,
|
||||
remoteEndPoint);
|
||||
throw new SecurityException("Forbidden.");
|
||||
}
|
||||
|
||||
if (!user.IsParentalScheduleAllowed())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).",
|
||||
username,
|
||||
remoteEndPoint);
|
||||
throw new SecurityException("User is not allowed access at this time.");
|
||||
}
|
||||
|
||||
// Update LastActivityDate and LastLoginDate, then save
|
||||
if (success)
|
||||
{
|
||||
if (isUserSession)
|
||||
{
|
||||
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
|
||||
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
user.AuthenticationProviderId = providerId;
|
||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
user.InvalidLoginAttemptCount = 0;
|
||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
||||
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
||||
}
|
||||
else
|
||||
{
|
||||
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||
user.Username,
|
||||
remoteEndPoint);
|
||||
if (user is null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||
username,
|
||||
remoteEndPoint);
|
||||
throw new AuthenticationException("Invalid username or password entered.");
|
||||
}
|
||||
|
||||
if (user.HasPermission(PermissionKind.IsDisabled))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).",
|
||||
username,
|
||||
remoteEndPoint);
|
||||
throw new SecurityException(
|
||||
$"The {user.Username} account is currently disabled. Please consult with your administrator.");
|
||||
}
|
||||
|
||||
if (!user.HasPermission(PermissionKind.EnableRemoteAccess) &&
|
||||
!_networkManager.IsInLocalNetwork(remoteEndPoint))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).",
|
||||
username,
|
||||
remoteEndPoint);
|
||||
throw new SecurityException("Forbidden.");
|
||||
}
|
||||
|
||||
if (!user.IsParentalScheduleAllowed())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).",
|
||||
username,
|
||||
remoteEndPoint);
|
||||
throw new SecurityException("User is not allowed access at this time.");
|
||||
}
|
||||
|
||||
// Update LastActivityDate and LastLoginDate, then save
|
||||
if (success)
|
||||
{
|
||||
if (isUserSession)
|
||||
{
|
||||
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
user.InvalidLoginAttemptCount = 0;
|
||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
||||
}
|
||||
else
|
||||
{
|
||||
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||
user.Username,
|
||||
remoteEndPoint);
|
||||
}
|
||||
}
|
||||
|
||||
return success ? user : null;
|
||||
@@ -539,22 +612,22 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
|
||||
if (_users.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultName = Environment.UserName;
|
||||
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
|
||||
{
|
||||
defaultName = "MyJellyfinUser";
|
||||
}
|
||||
|
||||
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultName = Environment.UserName;
|
||||
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
|
||||
{
|
||||
defaultName = "MyJellyfinUser";
|
||||
}
|
||||
|
||||
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
||||
|
||||
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
|
||||
newUser.SetPermission(PermissionKind.IsAdministrator, true);
|
||||
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
|
||||
@@ -562,7 +635,6 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
dbContext.Users.Add(newUser);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
_users.Add(newUser.Id, newUser);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,124 +671,120 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||
{
|
||||
var user = dbContext.Users
|
||||
.Include(u => u.Permissions)
|
||||
.Include(u => u.Preferences)
|
||||
.Include(u => u.AccessSchedules)
|
||||
.Include(u => u.ProfileImage)
|
||||
.AsSingleQuery()
|
||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||
?? throw new ArgumentException("No user exists with given Id!");
|
||||
|
||||
user.SubtitleMode = config.SubtitleMode;
|
||||
user.HidePlayedInLatest = config.HidePlayedInLatest;
|
||||
user.EnableLocalPassword = config.EnableLocalPassword;
|
||||
user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack;
|
||||
user.DisplayCollectionsView = config.DisplayCollectionsView;
|
||||
user.DisplayMissingEpisodes = config.DisplayMissingEpisodes;
|
||||
user.AudioLanguagePreference = config.AudioLanguagePreference;
|
||||
user.RememberAudioSelections = config.RememberAudioSelections;
|
||||
user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay;
|
||||
user.RememberSubtitleSelections = config.RememberSubtitleSelections;
|
||||
user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
|
||||
|
||||
// Only set cast receiver id if it is passed in and it exists in the server config.
|
||||
if (!string.IsNullOrEmpty(config.CastReceiverId)
|
||||
&& _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
user.CastReceiverId = config.CastReceiverId;
|
||||
var user = UserQuery(dbContext)
|
||||
.AsTracking()
|
||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||
?? throw new ArgumentException("No user exists with given Id!");
|
||||
|
||||
user.SubtitleMode = config.SubtitleMode;
|
||||
user.HidePlayedInLatest = config.HidePlayedInLatest;
|
||||
user.EnableLocalPassword = config.EnableLocalPassword;
|
||||
user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack;
|
||||
user.DisplayCollectionsView = config.DisplayCollectionsView;
|
||||
user.DisplayMissingEpisodes = config.DisplayMissingEpisodes;
|
||||
user.AudioLanguagePreference = config.AudioLanguagePreference;
|
||||
user.RememberAudioSelections = config.RememberAudioSelections;
|
||||
user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay;
|
||||
user.RememberSubtitleSelections = config.RememberSubtitleSelections;
|
||||
user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
|
||||
|
||||
// Only set cast receiver id if it is passed in and it exists in the server config.
|
||||
if (!string.IsNullOrEmpty(config.CastReceiverId)
|
||||
&& _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
|
||||
{
|
||||
user.CastReceiverId = config.CastReceiverId;
|
||||
}
|
||||
|
||||
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
|
||||
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
|
||||
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
|
||||
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
||||
|
||||
dbContext.Update(user);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
|
||||
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
|
||||
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
|
||||
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
||||
|
||||
dbContext.Update(user);
|
||||
_users[user.Id] = user;
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||
{
|
||||
var user = dbContext.Users
|
||||
.Include(u => u.Permissions)
|
||||
.Include(u => u.Preferences)
|
||||
.Include(u => u.AccessSchedules)
|
||||
.Include(u => u.ProfileImage)
|
||||
.AsSingleQuery()
|
||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||
?? throw new ArgumentException("No user exists with given Id!");
|
||||
|
||||
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
|
||||
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
-1 => null,
|
||||
0 => 3,
|
||||
_ => policy.LoginAttemptsBeforeLockout
|
||||
};
|
||||
var user = UserQuery(dbContext)
|
||||
.AsTracking()
|
||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||
?? throw new ArgumentException("No user exists with given Id!");
|
||||
|
||||
user.MaxParentalRatingScore = policy.MaxParentalRating;
|
||||
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
|
||||
user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
|
||||
user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
|
||||
user.AuthenticationProviderId = policy.AuthenticationProviderId;
|
||||
user.PasswordResetProviderId = policy.PasswordResetProviderId;
|
||||
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
|
||||
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
|
||||
user.MaxActiveSessions = policy.MaxActiveSessions;
|
||||
user.SyncPlayAccess = policy.SyncPlayAccess;
|
||||
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
|
||||
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
|
||||
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
|
||||
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
|
||||
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
|
||||
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
|
||||
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
|
||||
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
|
||||
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
|
||||
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
|
||||
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
|
||||
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
|
||||
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
|
||||
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
|
||||
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
|
||||
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
|
||||
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
|
||||
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
|
||||
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
|
||||
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
|
||||
user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
|
||||
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
|
||||
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
|
||||
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
|
||||
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
|
||||
{
|
||||
-1 => null,
|
||||
0 => 3,
|
||||
_ => policy.LoginAttemptsBeforeLockout
|
||||
};
|
||||
|
||||
user.AccessSchedules.Clear();
|
||||
foreach (var policyAccessSchedule in policy.AccessSchedules)
|
||||
{
|
||||
user.AccessSchedules.Add(policyAccessSchedule);
|
||||
user.MaxParentalRatingScore = policy.MaxParentalRating;
|
||||
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
|
||||
user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
|
||||
user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
|
||||
user.AuthenticationProviderId = policy.AuthenticationProviderId;
|
||||
user.PasswordResetProviderId = policy.PasswordResetProviderId;
|
||||
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
|
||||
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
|
||||
user.MaxActiveSessions = policy.MaxActiveSessions;
|
||||
user.SyncPlayAccess = policy.SyncPlayAccess;
|
||||
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
|
||||
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
|
||||
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
|
||||
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
|
||||
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
|
||||
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
|
||||
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
|
||||
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
|
||||
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
|
||||
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
|
||||
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
|
||||
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
|
||||
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
|
||||
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
|
||||
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
|
||||
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
|
||||
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
|
||||
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
|
||||
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
|
||||
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
|
||||
user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
|
||||
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
|
||||
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
|
||||
|
||||
user.AccessSchedules.Clear();
|
||||
foreach (var policyAccessSchedule in policy.AccessSchedules)
|
||||
{
|
||||
user.AccessSchedules.Add(policyAccessSchedule);
|
||||
}
|
||||
|
||||
// TODO: fix this at some point
|
||||
user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>());
|
||||
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
|
||||
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags);
|
||||
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
|
||||
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
|
||||
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
|
||||
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
||||
|
||||
dbContext.Update(user);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// TODO: fix this at some point
|
||||
user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>());
|
||||
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
|
||||
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags);
|
||||
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
|
||||
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
|
||||
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
|
||||
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
||||
|
||||
dbContext.Update(user);
|
||||
_users[user.Id] = user;
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,15 +796,17 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
return;
|
||||
}
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||
{
|
||||
dbContext.Remove(user.ProfileImage);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
dbContext.Remove(user.ProfileImage);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
user.ProfileImage = null;
|
||||
_users[user.Id] = user;
|
||||
user.ProfileImage = null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ThrowIfInvalidUsername(string name)
|
||||
@@ -882,15 +952,42 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
user.InvalidLoginAttemptCount);
|
||||
}
|
||||
|
||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpdateUserInternalAsync(User user)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
|
||||
{
|
||||
dbContext.Users.Attach(user);
|
||||
dbContext.Entry(user).State = EntityState.Modified;
|
||||
_users[user.Id] = user;
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes all members of this class.
|
||||
/// </summary>
|
||||
/// <param name="disposing">Defines if the class has been cleaned up by a dispose or finalizer.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_userLock.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
Jellyfin.Server/Configuration/StartupMode.cs
Normal file
24
Jellyfin.Server/Configuration/StartupMode.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using MediaBrowser.Model.Configuration;
|
||||
|
||||
namespace Jellyfin.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Defines types for usage with the <see cref="StartupOptions.StartupMode"/>.
|
||||
/// </summary>
|
||||
public enum StartupMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Default startup mode, runs the jellyfin server in normal operation.
|
||||
/// </summary>
|
||||
MediaServer = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to Migrate the system only then shuts down.
|
||||
/// </summary>
|
||||
MigrateSystem = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Runs the Database seed function regardless of <see cref="BaseApplicationConfiguration.IsStartupWizardCompleted"/> state.
|
||||
/// </summary>
|
||||
SeedSystem = 2
|
||||
}
|
||||
@@ -90,7 +90,7 @@ internal class JellyfinMigrationService
|
||||
|
||||
private HashSet<MigrationStage> Migrations { get; set; }
|
||||
|
||||
public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
|
||||
public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths, StartupOptions startupOptions)
|
||||
{
|
||||
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
|
||||
logger.LogInformation("Initialise Migration service.");
|
||||
@@ -98,9 +98,9 @@ internal class JellyfinMigrationService
|
||||
var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
|
||||
? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)!
|
||||
: new ServerConfiguration();
|
||||
if (!serverConfig.IsStartupWizardCompleted)
|
||||
if (!serverConfig.IsStartupWizardCompleted || startupOptions.StartupMode is Configuration.StartupMode.SeedSystem)
|
||||
{
|
||||
logger.LogInformation("System initialisation detected. Seed data.");
|
||||
logger.LogInformation("System initialization detected. Seed data. Startup mode is: {StartupMode}", startupOptions.StartupMode ?? Configuration.StartupMode.MediaServer);
|
||||
var flatApplyMigrations = Migrations.SelectMany(e => e.Where(f => !f.Metadata.RunMigrationOnSetup)).ToArray();
|
||||
|
||||
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to disable legacy authorization in the system config.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2025-11-18T16:00:00", nameof(DisableLegacyAuthorization))]
|
||||
public class DisableLegacyAuthorization : IAsyncMigrationRoutine
|
||||
{
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class.
|
||||
/// </summary>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
|
||||
_serverConfigurationManager.SaveConfiguration();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Merges MusicArtist records that differ only by Name casing. Prior to the case-insensitive
|
||||
/// dedup lookup added alongside this migration, the artist validator would create a second
|
||||
/// MusicArtist whenever a track tagged the artist with a different casing than the
|
||||
/// resolver-created one (e.g. "Thirty Seconds To Mars" vs. "Thirty Seconds to Mars").
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-05-08T12:00:00", nameof(MergeDuplicateMusicArtists))]
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine
|
||||
{
|
||||
private const string MusicArtistType = "MediaBrowser.Controller.Entities.Audio.MusicArtist";
|
||||
|
||||
private readonly IStartupLogger<MergeDuplicateMusicArtists> _logger;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IItemPersistenceService _persistenceService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MergeDuplicateMusicArtists"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The startup logger.</param>
|
||||
/// <param name="dbContextFactory">The database context factory.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="persistenceService">The item persistence service.</param>
|
||||
public MergeDuplicateMusicArtists(
|
||||
IStartupLogger<MergeDuplicateMusicArtists> logger,
|
||||
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||
ILibraryManager libraryManager,
|
||||
IItemPersistenceService persistenceService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_libraryManager = libraryManager;
|
||||
_persistenceService = persistenceService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
var artists = await context.BaseItems
|
||||
.Where(b => b.Type == MusicArtistType && b.Name != null)
|
||||
.Select(b => new { b.Id, b.Name, b.DateCreated })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var groups = artists
|
||||
.GroupBy(a => a.Name!.ToLowerInvariant())
|
||||
.Where(g => g.Count() > 1)
|
||||
.ToList();
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No case-only duplicate MusicArtist records found.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} groups of case-only duplicate MusicArtist records.", groups.Count);
|
||||
|
||||
var idsToDelete = new List<Guid>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var groupIds = group.Select(g => g.Id).ToArray();
|
||||
|
||||
// Pick the keeper: the artist with the most child references is the "real" one
|
||||
// (the resolver-created artist with a filesystem path); the duplicates are usually
|
||||
// empty stubs created by the validator's case-sensitive miss.
|
||||
var stats = await context.BaseItems
|
||||
.Where(b => groupIds.Contains(b.Id))
|
||||
.Select(b => new
|
||||
{
|
||||
b.Id,
|
||||
b.Name,
|
||||
b.DateCreated,
|
||||
ChildCount = context.BaseItems.Count(c => c.ParentId == b.Id),
|
||||
AncestorCount = context.AncestorIds.Count(a => a.ParentItemId == b.Id),
|
||||
LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id),
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var keeper = stats
|
||||
.OrderByDescending(s => s.ChildCount)
|
||||
.ThenByDescending(s => s.AncestorCount)
|
||||
.ThenByDescending(s => s.LinkedCount)
|
||||
.ThenBy(s => s.DateCreated)
|
||||
.First();
|
||||
|
||||
foreach (var dup in stats.Where(s => s.Id != keeper.Id))
|
||||
{
|
||||
var keeperId = keeper.Id;
|
||||
var dupId = dup.Id;
|
||||
|
||||
await context.BaseItems
|
||||
.Where(b => b.ParentId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await context.BaseItems
|
||||
.Where(b => b.OwnerId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// AncestorIds PK is (ItemId, ParentItemId); drop rows that would collide before redirecting.
|
||||
await context.AncestorIds
|
||||
.Where(a => a.ParentItemId == dupId
|
||||
&& context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.AncestorIds
|
||||
.Where(a => a.ParentItemId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// LinkedChildren PK is (ParentId, ChildId); drop colliding rows in both directions.
|
||||
await context.LinkedChildren
|
||||
.Where(l => l.ParentId == dupId
|
||||
&& context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.LinkedChildren
|
||||
.Where(l => l.ParentId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.LinkedChildren
|
||||
.Where(l => l.ChildId == dupId
|
||||
&& context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.LinkedChildren
|
||||
.Where(l => l.ChildId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// UserData has UNIQUE(UserId, CustomDataKey); keep the dup's row only when the
|
||||
// keeper has no equivalent row, otherwise the keeper's value wins.
|
||||
await context.UserData
|
||||
.Where(u => u.ItemId == dupId
|
||||
&& context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.UserData
|
||||
.Where(u => u.ItemId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
idsToDelete.Add(dupId);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Merged duplicates for '{Name}' into {KeeperId} ({Removed} removed).",
|
||||
keeper.Name,
|
||||
keeper.Id,
|
||||
stats.Count - 1);
|
||||
}
|
||||
|
||||
if (idsToDelete.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
||||
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
|
||||
// Fall back to the persistence service for any items the LibraryManager can't resolve.
|
||||
var itemsToDelete = idsToDelete
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
if (itemsToDelete.Count > 0)
|
||||
{
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
}
|
||||
|
||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
||||
if (unresolvedIds.Count > 0)
|
||||
{
|
||||
_persistenceService.DeleteItem(unresolvedIds);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
294
Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs
Normal file
294
Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs
Normal file
@@ -0,0 +1,294 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Merges case-only duplicate people. Two passes:
|
||||
/// 1) Person BaseItems whose Name differs only by casing — Person.GetPath hashes the name
|
||||
/// verbatim, so two casings produce two distinct Person rows in BaseItems.
|
||||
/// 2) Peoples lookup rows whose Name differs only by casing within the same PersonType —
|
||||
/// UpdatePeople used to insert a second Peoples row when a metadata provider returned
|
||||
/// a different casing than the row already in the table.
|
||||
/// Both bugs cause the /Persons endpoint to list the same person twice.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-05-08T13:00:00", nameof(MergeDuplicatePeople))]
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
public class MergeDuplicatePeople : IAsyncMigrationRoutine
|
||||
{
|
||||
private const string PersonType = "MediaBrowser.Controller.Entities.Person";
|
||||
|
||||
private readonly IStartupLogger<MergeDuplicatePeople> _logger;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IItemPersistenceService _persistenceService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MergeDuplicatePeople"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The startup logger.</param>
|
||||
/// <param name="dbContextFactory">The database context factory.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="persistenceService">The item persistence service.</param>
|
||||
public MergeDuplicatePeople(
|
||||
IStartupLogger<MergeDuplicatePeople> logger,
|
||||
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||
ILibraryManager libraryManager,
|
||||
IItemPersistenceService persistenceService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_libraryManager = libraryManager;
|
||||
_persistenceService = persistenceService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
await MergePersonBaseItemsAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await MergePeoplesRowsAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MergePersonBaseItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var persons = await context.BaseItems
|
||||
.Where(b => b.Type == PersonType && b.Name != null)
|
||||
.Select(b => new { b.Id, b.Name, b.DateCreated })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var groups = persons
|
||||
.GroupBy(p => p.Name!.ToLowerInvariant())
|
||||
.Where(g => g.Count() > 1)
|
||||
.ToList();
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No case-only duplicate Person BaseItems found.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} groups of case-only duplicate Person BaseItems.", groups.Count);
|
||||
|
||||
var idsToDelete = new List<Guid>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var groupIds = group.Select(g => g.Id).ToArray();
|
||||
|
||||
// Pick the keeper: the Person with the most UserData rows (favorites, image
|
||||
// refresh state) is the one users have actually interacted with.
|
||||
var stats = await context.BaseItems
|
||||
.Where(b => groupIds.Contains(b.Id))
|
||||
.Select(b => new
|
||||
{
|
||||
b.Id,
|
||||
b.Name,
|
||||
b.DateCreated,
|
||||
UserDataCount = context.UserData.Count(u => u.ItemId == b.Id),
|
||||
LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id),
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var keeper = stats
|
||||
.OrderByDescending(s => s.UserDataCount)
|
||||
.ThenByDescending(s => s.LinkedCount)
|
||||
.ThenBy(s => s.DateCreated)
|
||||
.First();
|
||||
|
||||
foreach (var dup in stats.Where(s => s.Id != keeper.Id))
|
||||
{
|
||||
var keeperId = keeper.Id;
|
||||
var dupId = dup.Id;
|
||||
|
||||
await context.BaseItems
|
||||
.Where(b => b.ParentId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await context.BaseItems
|
||||
.Where(b => b.OwnerId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await context.AncestorIds
|
||||
.Where(a => a.ParentItemId == dupId
|
||||
&& context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.AncestorIds
|
||||
.Where(a => a.ParentItemId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await context.LinkedChildren
|
||||
.Where(l => l.ParentId == dupId
|
||||
&& context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.LinkedChildren
|
||||
.Where(l => l.ParentId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.LinkedChildren
|
||||
.Where(l => l.ChildId == dupId
|
||||
&& context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.LinkedChildren
|
||||
.Where(l => l.ChildId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await context.UserData
|
||||
.Where(u => u.ItemId == dupId
|
||||
&& context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.UserData
|
||||
.Where(u => u.ItemId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
idsToDelete.Add(dupId);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Merged Person BaseItems for '{Name}' into {KeeperId} ({Removed} removed).",
|
||||
keeper.Name,
|
||||
keeper.Id,
|
||||
stats.Count - 1);
|
||||
}
|
||||
|
||||
if (idsToDelete.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
||||
// %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
|
||||
var itemsToDelete = idsToDelete
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
if (itemsToDelete.Count > 0)
|
||||
{
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
}
|
||||
|
||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
||||
if (unresolvedIds.Count > 0)
|
||||
{
|
||||
_persistenceService.DeleteItem(unresolvedIds);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count);
|
||||
}
|
||||
|
||||
private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var people = await context.Peoples
|
||||
.Select(p => new { p.Id, p.Name, p.PersonType })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var groups = people
|
||||
.GroupBy(p => (Name: p.Name.ToLowerInvariant(), p.PersonType))
|
||||
.Where(g => g.Count() > 1)
|
||||
.ToList();
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No case-only duplicate Peoples rows found.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} groups of case-only duplicate Peoples rows.", groups.Count);
|
||||
|
||||
var idsToDelete = new List<Guid>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var groupIds = group.Select(g => g.Id).ToArray();
|
||||
|
||||
// Pick the keeper: the row referenced by the most BaseItems is the one most
|
||||
// tracks/movies already point at; the duplicates are usually orphan stubs left
|
||||
// by a casing-mismatched insert.
|
||||
var stats = await context.Peoples
|
||||
.Where(p => groupIds.Contains(p.Id))
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.Name,
|
||||
MapCount = context.PeopleBaseItemMap.Count(m => m.PeopleId == p.Id),
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var keeper = stats
|
||||
.OrderByDescending(s => s.MapCount)
|
||||
.ThenBy(s => s.Id)
|
||||
.First();
|
||||
|
||||
foreach (var dup in stats.Where(s => s.Id != keeper.Id))
|
||||
{
|
||||
var keeperId = keeper.Id;
|
||||
var dupId = dup.Id;
|
||||
|
||||
// PeopleBaseItemMap PK is (ItemId, PeopleId, Role); drop dup rows that would
|
||||
// collide on (ItemId, Role) before redirecting PeopleId. Role is nullable, so
|
||||
// match nulls explicitly.
|
||||
await context.PeopleBaseItemMap
|
||||
.Where(m => m.PeopleId == dupId
|
||||
&& context.PeopleBaseItemMap.Any(k => k.PeopleId == keeperId
|
||||
&& k.ItemId == m.ItemId
|
||||
&& (k.Role == m.Role || (k.Role == null && m.Role == null))))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await context.PeopleBaseItemMap
|
||||
.Where(m => m.PeopleId == dupId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PeopleId, keeperId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
idsToDelete.Add(dupId);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Merged Peoples rows for '{Name}' into {KeeperId} ({Removed} removed).",
|
||||
keeper.Name,
|
||||
keeper.Id,
|
||||
stats.Count - 1);
|
||||
}
|
||||
|
||||
if (idsToDelete.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await context.Peoples
|
||||
.Where(p => idsToDelete.Contains(p.Id))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Removed {Count} duplicate Peoples rows.", idsToDelete.Count);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -283,9 +284,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
|
||||
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", itemsToDelete.Count);
|
||||
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted);
|
||||
}
|
||||
|
||||
private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context)
|
||||
@@ -314,9 +315,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
|
||||
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", itemsToDelete.Count);
|
||||
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted);
|
||||
}
|
||||
|
||||
private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context)
|
||||
@@ -343,9 +344,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
|
||||
_logger.LogInformation("Removed {Count} items from deleted libraries.", itemsToDelete.Count);
|
||||
_logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
|
||||
}
|
||||
|
||||
private void CleanupStaleFileEntries(JellyfinDbContext context)
|
||||
@@ -431,9 +432,34 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
|
||||
_logger.LogInformation("Removed {Count} stale items.", itemsToDelete.Count);
|
||||
_logger.LogInformation("Removed {Count} stale items.", deleted);
|
||||
}
|
||||
|
||||
private int DeleteItems(IReadOnlyCollection<BaseItem> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
|
||||
var deleted = 0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
try
|
||||
{
|
||||
_libraryManager.DeleteItem(item, options);
|
||||
deleted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Skipping item {ItemId} ({ItemName}): delete failed.", item.Id, item.Name ?? "Unknown");
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private void CleanupOrphanedLinkedChildren(JellyfinDbContext context)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
@@ -12,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines;
|
||||
/// Migrate rating levels.
|
||||
/// </summary>
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
[JellyfinMigration("2025-04-20T22:00:00", nameof(MigrateRatingLevels))]
|
||||
[JellyfinMigration("2026-03-02T09:00:00", nameof(MigrateRatingLevels))]
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
internal class MigrateRatingLevels : IDatabaseMigrationRoutine
|
||||
|
||||
@@ -144,6 +144,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
|
||||
}
|
||||
|
||||
var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension);
|
||||
if (newSubtitleCachePath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (File.Exists(newSubtitleCachePath))
|
||||
{
|
||||
File.Delete(oldSubtitleCachePath);
|
||||
@@ -182,6 +187,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
|
||||
}
|
||||
|
||||
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
|
||||
if (newAttachmentPath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (File.Exists(newAttachmentPath))
|
||||
{
|
||||
File.Delete(oldAttachmentPath);
|
||||
|
||||
@@ -137,7 +137,7 @@ namespace Jellyfin.Server
|
||||
|
||||
StartupHelpers.PerformStaticInitialization();
|
||||
|
||||
await ApplyStartupMigrationAsync(appPaths, startupConfig).ConfigureAwait(false);
|
||||
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
|
||||
|
||||
do
|
||||
{
|
||||
@@ -214,13 +214,17 @@ namespace Jellyfin.Server
|
||||
{
|
||||
configurationCompleted = true;
|
||||
await _setupServer!.StopAsync().ConfigureAwait(false);
|
||||
await _jellyfinHost.StartAsync().ConfigureAwait(false);
|
||||
|
||||
if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket())
|
||||
if (options.StartupMode is null or Configuration.StartupMode.MediaServer)
|
||||
{
|
||||
var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths);
|
||||
await _jellyfinHost.StartAsync().ConfigureAwait(false);
|
||||
|
||||
StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger);
|
||||
if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket())
|
||||
{
|
||||
var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths);
|
||||
|
||||
StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -229,11 +233,14 @@ namespace Jellyfin.Server
|
||||
throw;
|
||||
}
|
||||
|
||||
await appHost.RunStartupTasksAsync().ConfigureAwait(false);
|
||||
if (options.StartupMode is null or Configuration.StartupMode.MediaServer)
|
||||
{
|
||||
await appHost.RunStartupTasksAsync().ConfigureAwait(false);
|
||||
_logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp));
|
||||
|
||||
_logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp));
|
||||
await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
|
||||
_restartOnShutdown = appHost.ShouldRestart;
|
||||
_restoreFromBackup = appHost.RestoreBackupPath;
|
||||
}
|
||||
@@ -244,7 +251,11 @@ namespace Jellyfin.Server
|
||||
if (_setupServer!.IsAlive && !configurationCompleted)
|
||||
{
|
||||
_setupServer!.SoftStop();
|
||||
await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false);
|
||||
if (options.StartupMode is null or Configuration.StartupMode.MediaServer)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _setupServer!.StopAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -275,8 +286,9 @@ namespace Jellyfin.Server
|
||||
/// </remarks>
|
||||
/// <param name="appPaths">Application Paths.</param>
|
||||
/// <param name="startupConfig">Startup Config.</param>
|
||||
/// <param name="startupOptions">The applications startup options.</param>
|
||||
/// <returns>A task.</returns>
|
||||
public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig)
|
||||
public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig, StartupOptions startupOptions)
|
||||
{
|
||||
_migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service");
|
||||
var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
|
||||
@@ -294,7 +306,7 @@ namespace Jellyfin.Server
|
||||
PrepareDatabaseProvider(startupService);
|
||||
|
||||
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService);
|
||||
await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths).ConfigureAwait(false);
|
||||
await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false);
|
||||
await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using CommandLine;
|
||||
using Emby.Server.Implementations;
|
||||
using Jellyfin.Server.Configuration;
|
||||
using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
|
||||
|
||||
namespace Jellyfin.Server
|
||||
@@ -79,6 +80,13 @@ namespace Jellyfin.Server
|
||||
[Option("restore-archive", Required = false, HelpText = "Path to a Jellyfin backup archive to restore from")]
|
||||
public string? RestoreArchive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the mode of operation the server should perform when started.
|
||||
/// Defaults to: <see cref="StartupMode.MediaServer"/>.
|
||||
/// </summary>
|
||||
[Option("mode", Required = false, HelpText = "Mode which selects what action the jellyfin server should perform when started.")]
|
||||
public StartupMode? StartupMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command line options as a dictionary that can be used in the .NET configuration system.
|
||||
/// </summary>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Common</PackageId>
|
||||
<VersionPrefix>10.12.0</VersionPrefix>
|
||||
<VersionPrefix>12.0.0</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Net.Sockets;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Common.Net;
|
||||
|
||||
@@ -166,8 +167,9 @@ public static partial class NetworkUtils
|
||||
/// <param name="values">Input string array to be parsed.</param>
|
||||
/// <param name="result">Collection of <see cref="IPNetwork"/>.</param>
|
||||
/// <param name="negated">Boolean signaling if negated or not negated values should be parsed.</param>
|
||||
/// <param name="logger">Optional logger used to warn about entries that fail to parse.</param>
|
||||
/// <returns><c>True</c> if parsing was successful.</returns>
|
||||
public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPData>? result, bool negated = false)
|
||||
public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPData>? result, bool negated = false, ILogger? logger = null)
|
||||
{
|
||||
if (values is null || values.Length == 0)
|
||||
{
|
||||
@@ -182,12 +184,45 @@ public static partial class NetworkUtils
|
||||
{
|
||||
(tmpResult ??= new()).Add(innerResult);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogInvalidSubnet(logger, values[a]);
|
||||
}
|
||||
}
|
||||
|
||||
result = tmpResult;
|
||||
return result is not null;
|
||||
}
|
||||
|
||||
private static void LogInvalidSubnet(ILogger? logger, string value)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmed = value.AsSpan().Trim();
|
||||
if (trimmed.StartsWith('!'))
|
||||
{
|
||||
trimmed = trimmed[1..];
|
||||
}
|
||||
|
||||
var slash = trimmed.IndexOf('/');
|
||||
if (slash != -1
|
||||
&& trimmed.Contains(':')
|
||||
&& trimmed.IndexOf("::", StringComparison.Ordinal) == -1)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Invalid IPv6 subnet '{Subnet}': IPv6 prefix-only notation is not supported. Use the full notation including '::' (e.g. '{Example}::/{Prefix}').",
|
||||
value,
|
||||
trimmed[..slash].ToString(),
|
||||
trimmed[(slash + 1)..].ToString());
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogWarning("Invalid subnet '{Subnet}' will be ignored.", value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try parsing a string into an <see cref="IPData"/>, respecting exclusions.
|
||||
/// Inputs without a subnet mask will be represented as <see cref="IPData"/> with a single IP.
|
||||
|
||||
@@ -216,6 +216,9 @@ namespace MediaBrowser.Controller.Entities
|
||||
[JsonIgnore]
|
||||
public string OriginalTitle { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string OriginalLanguage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the id.
|
||||
/// </summary>
|
||||
|
||||
@@ -22,30 +22,30 @@ public interface IPathManager
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="streamIndex">The stream index.</param>
|
||||
/// <param name="extension">The subtitle file extension.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension);
|
||||
/// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns>
|
||||
public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the subtitle file.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetSubtitleFolderPath(string mediaSourceId);
|
||||
/// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns>
|
||||
public string? GetSubtitleFolderPath(string mediaSourceId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the attachment file.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="fileName">The attachmentFileName index.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetAttachmentPath(string mediaSourceId, string fileName);
|
||||
/// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns>
|
||||
public string? GetAttachmentPath(string mediaSourceId, string fileName);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the attachment folder.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetAttachmentFolderPath(string mediaSourceId);
|
||||
/// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns>
|
||||
public string? GetAttachmentFolderPath(string mediaSourceId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chapter images data path.
|
||||
|
||||
@@ -24,14 +24,14 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <summary>
|
||||
/// Gets the users.
|
||||
/// </summary>
|
||||
/// <value>The users.</value>
|
||||
IEnumerable<User> Users { get; }
|
||||
/// <returns>The users.</returns>
|
||||
IEnumerable<User> GetUsers();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user ids.
|
||||
/// </summary>
|
||||
/// <value>The users ids.</value>
|
||||
IEnumerable<Guid> UsersIds { get; }
|
||||
/// <returns>The users ids.</returns>
|
||||
IEnumerable<Guid> GetUsersIds();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the user manager and ensures that a user exists.
|
||||
@@ -47,6 +47,12 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <exception cref="ArgumentException"><c>id</c> is an empty Guid.</exception>
|
||||
User? GetUserById(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first available user.
|
||||
/// </summary>
|
||||
/// <returns>The first user, or <c>null</c> if no users exist.</returns>
|
||||
User? GetFirstUser();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the user by.
|
||||
/// </summary>
|
||||
@@ -57,12 +63,13 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <summary>
|
||||
/// Renames the user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="userId">The UserId to change.</param>
|
||||
/// <param name="oldName">The old Username.</param>
|
||||
/// <param name="newName">The new name.</param>
|
||||
/// <returns>Task.</returns>
|
||||
/// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception>
|
||||
/// <exception cref="ArgumentException">If the provided user doesn't exist.</exception>
|
||||
Task RenameUser(User user, string newName);
|
||||
Task RenameUser(Guid userId, string oldName, string newName);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the user.
|
||||
@@ -92,17 +99,17 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <summary>
|
||||
/// Resets the password.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="userId">The users Id.</param>
|
||||
/// <returns>Task.</returns>
|
||||
Task ResetPassword(User user);
|
||||
Task ResetPassword(Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// Changes the password.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="userId">The users id.</param>
|
||||
/// <param name="newPassword">New password to use.</param>
|
||||
/// <returns>Awaitable task.</returns>
|
||||
Task ChangePassword(User user, string newPassword);
|
||||
Task ChangePassword(Guid userId, string newPassword);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user dto.
|
||||
|
||||
31
MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs
Normal file
31
MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.LiveTv;
|
||||
|
||||
/// <summary>
|
||||
/// Provides Schedules Direct specific operations.
|
||||
/// </summary>
|
||||
public interface ISchedulesDirectService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the available countries from the Schedules Direct API, using a file cache.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A stream containing the raw JSON response.</returns>
|
||||
Task<Stream> GetAvailableCountries(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the Schedules Direct daily image download limit is currently active.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> if the image limit has been hit and has not yet reset; otherwise <c>false</c>.</returns>
|
||||
bool IsImageDailyLimitActive();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the Schedules Direct service is available.
|
||||
/// Returns <c>false</c> if a permanent account error has occurred or a transient backoff is active.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> if the service can accept requests; otherwise <c>false</c>.</returns>
|
||||
bool IsServiceAvailable();
|
||||
}
|
||||
@@ -37,6 +37,12 @@ public interface ITunerHostManager
|
||||
/// <returns>The <see cref="TunerHostInfo"/>s.</returns>
|
||||
IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tuner host by id, cleans up associated caches, and triggers a guide refresh.
|
||||
/// </summary>
|
||||
/// <param name="id">The tuner host id to delete.</param>
|
||||
void DeleteTunerHost(string? id);
|
||||
|
||||
/// <summary>
|
||||
/// Scans for tuner devices that have changed URLs.
|
||||
/// </summary>
|
||||
|
||||
@@ -109,7 +109,7 @@ namespace MediaBrowser.Controller.LiveTv
|
||||
{
|
||||
if (double.TryParse(Number, CultureInfo.InvariantCulture, out double number))
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty);
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0:0000000000.00000}", number) + "-" + (Name ?? string.Empty);
|
||||
}
|
||||
|
||||
return (Number ?? string.Empty) + "-" + (Name ?? string.Empty);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Controller</PackageId>
|
||||
<VersionPrefix>10.12.0</VersionPrefix>
|
||||
<VersionPrefix>12.0.0</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -91,6 +91,12 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
/// <value>The codec tag.</value>
|
||||
public string CodecTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the rotation.
|
||||
/// </summary>
|
||||
/// <value>The video rotation angle, usually 0 or +-90/180.</value>
|
||||
public string Rotation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the framerate.
|
||||
/// </summary>
|
||||
|
||||
@@ -1645,10 +1645,9 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase))
|
||||
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Override the too high default qmin 18 in transcoding preset
|
||||
// Override the too high default qmin 18 in transcoding preset in legacy h26x_amf
|
||||
return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}");
|
||||
}
|
||||
|
||||
@@ -1880,10 +1879,12 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
var sub2videoParam = enableSub2video ? ":sub2video=1" : string.Empty;
|
||||
|
||||
var fontPath = _pathManager.GetAttachmentFolderPath(state.MediaSource.Id);
|
||||
var fontParam = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
":fontsdir='{0}'",
|
||||
_mediaEncoder.EscapeSubtitleFilterPath(fontPath));
|
||||
var fontParam = fontPath is null
|
||||
? string.Empty
|
||||
: string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
":fontsdir='{0}'",
|
||||
_mediaEncoder.EscapeSubtitleFilterPath(fontPath));
|
||||
|
||||
if (state.SubtitleStream.IsExternal)
|
||||
{
|
||||
@@ -2466,6 +2467,17 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
}
|
||||
}
|
||||
|
||||
var requestedRotations = state.GetRequestedRotations(videoStream.Codec);
|
||||
if (requestedRotations.Length > 0)
|
||||
{
|
||||
var rotation = state.VideoStream?.Rotation ?? 0;
|
||||
if (rotation != 0
|
||||
&& !requestedRotations.Contains(rotation.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Video width must fall within requested value
|
||||
if (request.MaxWidth.HasValue
|
||||
&& (!videoStream.Width.HasValue || videoStream.Width.Value > request.MaxWidth.Value))
|
||||
|
||||
@@ -571,62 +571,50 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
|
||||
public string[] GetRequestedProfiles(string codec)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(BaseRequest.Profile))
|
||||
var profile = BaseRequest.Profile;
|
||||
|
||||
if (string.IsNullOrEmpty(profile) && !string.IsNullOrEmpty(codec))
|
||||
{
|
||||
return BaseRequest.Profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
profile = BaseRequest.GetOption(codec, "profile");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(codec))
|
||||
{
|
||||
var profile = BaseRequest.GetOption(codec, "profile");
|
||||
|
||||
if (!string.IsNullOrEmpty(profile))
|
||||
{
|
||||
return profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
return (profile ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
public string[] GetRequestedRangeTypes(string codec)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(BaseRequest.VideoRangeType))
|
||||
var rangetype = BaseRequest.VideoRangeType;
|
||||
|
||||
if (string.IsNullOrEmpty(rangetype) && !string.IsNullOrEmpty(codec))
|
||||
{
|
||||
return BaseRequest.VideoRangeType.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
rangetype = BaseRequest.GetOption(codec, "rangetype");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(codec))
|
||||
{
|
||||
var rangetype = BaseRequest.GetOption(codec, "rangetype");
|
||||
|
||||
if (!string.IsNullOrEmpty(rangetype))
|
||||
{
|
||||
return rangetype.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
return (rangetype ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
public string[] GetRequestedCodecTags(string codec)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(BaseRequest.CodecTag))
|
||||
var codectag = BaseRequest.CodecTag;
|
||||
|
||||
if (string.IsNullOrEmpty(codectag) && !string.IsNullOrEmpty(codec))
|
||||
{
|
||||
return BaseRequest.CodecTag.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
codectag = BaseRequest.GetOption(codec, "codectag");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(codec))
|
||||
{
|
||||
var codectag = BaseRequest.GetOption(codec, "codectag");
|
||||
return (codectag ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(codectag))
|
||||
{
|
||||
return codectag.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
public string[] GetRequestedRotations(string codec)
|
||||
{
|
||||
var rotation = BaseRequest.Rotation;
|
||||
|
||||
if (string.IsNullOrEmpty(rotation) && !string.IsNullOrEmpty(codec))
|
||||
{
|
||||
rotation = BaseRequest.GetOption(codec, "rotation");
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
return (rotation ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
public string GetRequestedLevel(string codec)
|
||||
|
||||
@@ -40,11 +40,6 @@ namespace MediaBrowser.Controller.Net
|
||||
/// </summary>
|
||||
private readonly List<(IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State)> _activeConnections = new();
|
||||
|
||||
/// <summary>
|
||||
/// The logger.
|
||||
/// </summary>
|
||||
protected readonly ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger;
|
||||
|
||||
private readonly Task _messageConsumerTask;
|
||||
|
||||
protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger)
|
||||
@@ -56,6 +51,11 @@ namespace MediaBrowser.Controller.Net
|
||||
_messageConsumerTask = HandleMessages();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Logger.
|
||||
/// </summary>
|
||||
protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type used for the messages sent to the client.
|
||||
/// </summary>
|
||||
|
||||
@@ -105,7 +105,7 @@ namespace MediaBrowser.Controller.Providers
|
||||
public IReadOnlyList<string> GetFilePaths(string path)
|
||||
=> GetFilePaths(path, false);
|
||||
|
||||
public IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false)
|
||||
public IReadOnlyList<string> GetFilePaths(string path, bool clearCache)
|
||||
{
|
||||
if (clearCache)
|
||||
{
|
||||
@@ -118,7 +118,7 @@ namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
try
|
||||
{
|
||||
return fileSystem.GetFilePaths(p).ToList();
|
||||
return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList();
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
@@ -127,11 +127,6 @@ namespace MediaBrowser.Controller.Providers
|
||||
},
|
||||
_fileSystem);
|
||||
|
||||
if (sort)
|
||||
{
|
||||
filePaths.Sort();
|
||||
}
|
||||
|
||||
return filePaths;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace MediaBrowser.Controller.Providers
|
||||
|
||||
IReadOnlyList<string> GetFilePaths(string path);
|
||||
|
||||
IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false);
|
||||
IReadOnlyList<string> GetFilePaths(string path, bool clearCache);
|
||||
|
||||
bool IsAccessible(string path);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,12 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
||||
ArgumentException.ThrowIfNullOrEmpty(inputPath);
|
||||
|
||||
var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
||||
if (outputFolder is null)
|
||||
{
|
||||
_logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile);
|
||||
return;
|
||||
}
|
||||
|
||||
using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var directory = Directory.CreateDirectory(outputFolder);
|
||||
@@ -241,9 +247,14 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
||||
if (attachmentFolderPath is null)
|
||||
{
|
||||
throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no attachment cache (non-GUID Id, e.g. Live TV stream).");
|
||||
}
|
||||
|
||||
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture));
|
||||
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!;
|
||||
if (!File.Exists(attachmentPath))
|
||||
{
|
||||
await ExtractAttachmentInternal(
|
||||
|
||||
@@ -729,6 +729,7 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
stream.Type = MediaStreamType.Audio;
|
||||
stream.LocalizedDefault = _localization.GetLocalizedString("Default");
|
||||
stream.LocalizedExternal = _localization.GetLocalizedString("External");
|
||||
stream.LocalizedOriginal = _localization.GetLocalizedString("Original");
|
||||
stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language)
|
||||
? null
|
||||
: _localization.FindLanguageInfo(stream.Language)?.DisplayName;
|
||||
@@ -1031,6 +1032,11 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
{
|
||||
stream.IsHearingImpaired = true;
|
||||
}
|
||||
|
||||
if (disposition.GetValueOrDefault("original") == 1)
|
||||
{
|
||||
stream.IsOriginal = true;
|
||||
}
|
||||
}
|
||||
|
||||
NormalizeStreamTitle(stream);
|
||||
@@ -1702,6 +1708,13 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip timestamp extration for remote resource (http, rtsp, etc.)
|
||||
// as they cannot be opened with FileStream
|
||||
if (video.Protocol != MediaProtocol.File)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(video.Container, "mpeg2ts", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(video.Container, "m2ts", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(video.Container, "ts", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -212,7 +212,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream);
|
||||
var outputFormat = GetExtractableSubtitleFormat(subtitleStream);
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension);
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension)
|
||||
?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUID Id, e.g. Live TV stream).");
|
||||
|
||||
return new SubtitleInfo()
|
||||
{
|
||||
@@ -242,7 +243,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
if (!_subtitleParser.SupportsFileExtension(currentFormat))
|
||||
{
|
||||
// Convert
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt");
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt")
|
||||
?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUID Id, e.g. Live TV stream).");
|
||||
|
||||
await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -520,6 +522,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
}
|
||||
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
|
||||
if (outputPath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -591,6 +597,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
}
|
||||
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
|
||||
if (outputPath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
|
||||
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
||||
|
||||
@@ -636,6 +647,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
}
|
||||
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
|
||||
if (outputPath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
|
||||
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
||||
|
||||
@@ -968,7 +984,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
|
||||
private string? GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
|
||||
{
|
||||
return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
|
||||
}
|
||||
@@ -981,9 +997,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
|
||||
await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var cachePath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
|
||||
if (cachePath is not null)
|
||||
{
|
||||
path = cachePath;
|
||||
await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -287,5 +287,5 @@ public class ServerConfiguration : BaseApplicationConfiguration
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether old authorization methods are allowed.
|
||||
/// </summary>
|
||||
public bool EnableLegacyAuthorization { get; set; }
|
||||
public bool EnableLegacyAuthorization { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace MediaBrowser.Model.Dlna
|
||||
/// <param name="numAudioStreams">The number of audio streams.</param>
|
||||
/// <param name="videoCodecTag">The video codec tag.</param>
|
||||
/// <param name="isAvc">A value indicating whether the video is AVC.</param>
|
||||
/// <param name="videoRotation">The video rotation angle, usually 0 or +-90/180.</param>
|
||||
/// <returns><b>True</b> if the condition is satisfied.</returns>
|
||||
public static bool IsVideoConditionSatisfied(
|
||||
ProfileCondition condition,
|
||||
@@ -53,7 +54,8 @@ namespace MediaBrowser.Model.Dlna
|
||||
int? numVideoStreams,
|
||||
int? numAudioStreams,
|
||||
string? videoCodecTag,
|
||||
bool? isAvc)
|
||||
bool? isAvc,
|
||||
int? videoRotation)
|
||||
{
|
||||
switch (condition.Property)
|
||||
{
|
||||
@@ -93,6 +95,8 @@ namespace MediaBrowser.Model.Dlna
|
||||
return IsConditionSatisfied(condition, numVideoStreams);
|
||||
case ProfileConditionValue.VideoTimestamp:
|
||||
return IsConditionSatisfied(condition, timestamp);
|
||||
case ProfileConditionValue.VideoRotation:
|
||||
return IsConditionSatisfied(condition, videoRotation);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ namespace MediaBrowser.Model.Dlna
|
||||
AudioSampleRate = 22,
|
||||
AudioBitDepth = 23,
|
||||
VideoRangeType = 24,
|
||||
NumStreams = 25
|
||||
NumStreams = 25,
|
||||
VideoRotation = 26
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace MediaBrowser.Model.Dlna
|
||||
internal const TranscodeReason ContainerReasons = TranscodeReason.ContainerNotSupported | TranscodeReason.ContainerBitrateExceedsLimit;
|
||||
internal const TranscodeReason AudioCodecReasons = TranscodeReason.AudioBitrateNotSupported | TranscodeReason.AudioChannelsNotSupported | TranscodeReason.AudioProfileNotSupported | TranscodeReason.AudioSampleRateNotSupported | TranscodeReason.SecondaryAudioNotSupported | TranscodeReason.AudioBitDepthNotSupported | TranscodeReason.AudioIsExternal;
|
||||
internal const TranscodeReason AudioReasons = TranscodeReason.AudioCodecNotSupported | AudioCodecReasons;
|
||||
internal const TranscodeReason VideoCodecReasons = TranscodeReason.VideoResolutionNotSupported | TranscodeReason.AnamorphicVideoNotSupported | TranscodeReason.InterlacedVideoNotSupported | TranscodeReason.VideoBitDepthNotSupported | TranscodeReason.VideoBitrateNotSupported | TranscodeReason.VideoFramerateNotSupported | TranscodeReason.VideoLevelNotSupported | TranscodeReason.RefFramesNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.VideoProfileNotSupported;
|
||||
internal const TranscodeReason VideoCodecReasons = TranscodeReason.VideoResolutionNotSupported | TranscodeReason.AnamorphicVideoNotSupported | TranscodeReason.InterlacedVideoNotSupported | TranscodeReason.VideoBitDepthNotSupported | TranscodeReason.VideoBitrateNotSupported | TranscodeReason.VideoFramerateNotSupported | TranscodeReason.VideoLevelNotSupported | TranscodeReason.RefFramesNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.VideoProfileNotSupported | TranscodeReason.VideoRotationNotSupported;
|
||||
internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons;
|
||||
internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported;
|
||||
|
||||
@@ -380,6 +380,9 @@ namespace MediaBrowser.Model.Dlna
|
||||
case ProfileConditionValue.VideoRangeType:
|
||||
return TranscodeReason.VideoRangeTypeNotSupported;
|
||||
|
||||
case ProfileConditionValue.VideoRotation:
|
||||
return TranscodeReason.VideoRotationNotSupported;
|
||||
|
||||
case ProfileConditionValue.VideoTimestamp:
|
||||
// TODO
|
||||
return 0;
|
||||
@@ -1040,6 +1043,7 @@ namespace MediaBrowser.Model.Dlna
|
||||
bool? isInterlaced = videoStream?.IsInterlaced;
|
||||
string? videoCodecTag = videoStream?.CodecTag;
|
||||
bool? isAvc = videoStream?.IsAVC;
|
||||
int? videoRotation = videoStream?.Rotation;
|
||||
|
||||
TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp;
|
||||
int? packetLength = videoStream?.PacketLength;
|
||||
@@ -1054,7 +1058,7 @@ namespace MediaBrowser.Model.Dlna
|
||||
var appliedVideoConditions = options.Profile.CodecProfiles
|
||||
.Where(i => i.Type == CodecType.Video &&
|
||||
i.ContainsAnyCodec(playlistItem.VideoCodecs, container, useSubContainer) &&
|
||||
i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc)))
|
||||
i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc, videoRotation)))
|
||||
// Reverse codec profiles for backward compatibility - first codec profile has higher priority
|
||||
.Reverse();
|
||||
foreach (var condition in appliedVideoConditions)
|
||||
@@ -2059,6 +2063,38 @@ namespace MediaBrowser.Model.Dlna
|
||||
break;
|
||||
}
|
||||
|
||||
case ProfileConditionValue.VideoRotation:
|
||||
{
|
||||
if (string.IsNullOrEmpty(qualifier))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// change from split by | to comma
|
||||
// strip spaces to avoid having to encode
|
||||
var values = value
|
||||
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (condition.Condition == ProfileConditionType.Equals)
|
||||
{
|
||||
item.SetOption(qualifier, "rotation", string.Join(',', values));
|
||||
}
|
||||
else if (condition.Condition == ProfileConditionType.EqualsAny)
|
||||
{
|
||||
var currentValue = item.GetOption(qualifier, "rotation");
|
||||
if (!string.IsNullOrEmpty(currentValue) && values.Any(v => string.Equals(v, currentValue, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
item.SetOption(qualifier, "rotation", currentValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
item.SetOption(qualifier, "rotation", string.Join(',', values));
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case ProfileConditionValue.Height:
|
||||
{
|
||||
if (!enableNonQualifiedConditions)
|
||||
@@ -2281,6 +2317,7 @@ namespace MediaBrowser.Model.Dlna
|
||||
bool? isInterlaced = videoStream?.IsInterlaced;
|
||||
string? videoCodecTag = videoStream?.CodecTag;
|
||||
bool? isAvc = videoStream?.IsAVC;
|
||||
int? videoRotation = videoStream?.Rotation;
|
||||
|
||||
TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : mediaSource.Timestamp;
|
||||
int? packetLength = videoStream?.PacketLength;
|
||||
@@ -2290,7 +2327,7 @@ namespace MediaBrowser.Model.Dlna
|
||||
int? numAudioStreams = mediaSource.GetStreamCount(MediaStreamType.Audio);
|
||||
int? numVideoStreams = mediaSource.GetStreamCount(MediaStreamType.Video);
|
||||
|
||||
return conditions.Where(applyCondition => !ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc));
|
||||
return conditions.Where(applyCondition => !ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc, videoRotation));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma warning disable CS1591
|
||||
#pragma warning disable CA1815
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
|
||||
@@ -800,5 +800,7 @@ namespace MediaBrowser.Model.Dto
|
||||
/// </summary>
|
||||
/// <value>The current program.</value>
|
||||
public BaseItemDto CurrentProgram { get; set; }
|
||||
|
||||
public string OriginalLanguage { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,8 @@ namespace MediaBrowser.Model.Entities
|
||||
|
||||
public string LocalizedLanguage { get; set; }
|
||||
|
||||
public string LocalizedOriginal { get; set; }
|
||||
|
||||
public string DisplayTitle
|
||||
{
|
||||
get
|
||||
@@ -267,161 +269,166 @@ namespace MediaBrowser.Model.Entities
|
||||
switch (Type)
|
||||
{
|
||||
case MediaStreamType.Audio:
|
||||
{
|
||||
var attributes = new List<string>();
|
||||
{
|
||||
var attributes = new List<string>();
|
||||
|
||||
// Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded).
|
||||
if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Use pre-resolved localized language name, falling back to raw language code.
|
||||
attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
attributes.Add(Profile);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(Codec))
|
||||
{
|
||||
attributes.Add(AudioCodec.GetFriendlyName(Codec));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ChannelLayout))
|
||||
{
|
||||
attributes.Add(StringHelper.FirstToUpper(ChannelLayout));
|
||||
}
|
||||
else if (Channels.HasValue)
|
||||
{
|
||||
attributes.Add(Channels.Value.ToString(CultureInfo.InvariantCulture) + " ch");
|
||||
}
|
||||
|
||||
if (IsDefault)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
|
||||
}
|
||||
|
||||
if (IsExternal)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
var result = new StringBuilder(Title);
|
||||
foreach (var tag in attributes)
|
||||
// Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded).
|
||||
if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep Tags that are not already in Title.
|
||||
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(" - ").Append(tag);
|
||||
}
|
||||
// Use pre-resolved localized language name, falling back to raw language code.
|
||||
attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
attributes.Add(Profile);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(Codec))
|
||||
{
|
||||
attributes.Add(AudioCodec.GetFriendlyName(Codec));
|
||||
}
|
||||
|
||||
return string.Join(" - ", attributes);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(ChannelLayout))
|
||||
{
|
||||
attributes.Add(StringHelper.FirstToUpper(ChannelLayout));
|
||||
}
|
||||
else if (Channels.HasValue)
|
||||
{
|
||||
attributes.Add(Channels.Value.ToString(CultureInfo.InvariantCulture) + " ch");
|
||||
}
|
||||
|
||||
if (IsDefault)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
|
||||
}
|
||||
|
||||
if (IsExternal)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
|
||||
}
|
||||
|
||||
if (IsOriginal)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedOriginal) ? "Original" : LocalizedOriginal);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
var result = new StringBuilder(Title);
|
||||
foreach (var tag in attributes)
|
||||
{
|
||||
// Keep Tags that are not already in Title.
|
||||
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(" - ").Append(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
return string.Join(" - ", attributes);
|
||||
}
|
||||
|
||||
case MediaStreamType.Video:
|
||||
{
|
||||
var attributes = new List<string>();
|
||||
|
||||
var resolutionText = GetResolutionText();
|
||||
|
||||
if (!string.IsNullOrEmpty(resolutionText))
|
||||
{
|
||||
attributes.Add(resolutionText);
|
||||
}
|
||||
var attributes = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(Codec))
|
||||
{
|
||||
attributes.Add(Codec.ToUpperInvariant());
|
||||
}
|
||||
var resolutionText = GetResolutionText();
|
||||
|
||||
if (VideoDoViTitle is not null)
|
||||
{
|
||||
attributes.Add(VideoDoViTitle);
|
||||
}
|
||||
else if (VideoRange != VideoRange.Unknown)
|
||||
{
|
||||
attributes.Add(VideoRange.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
var result = new StringBuilder(Title);
|
||||
foreach (var tag in attributes)
|
||||
if (!string.IsNullOrEmpty(resolutionText))
|
||||
{
|
||||
// Keep Tags that are not already in Title.
|
||||
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(" - ").Append(tag);
|
||||
}
|
||||
attributes.Add(resolutionText);
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Codec))
|
||||
{
|
||||
attributes.Add(Codec.ToUpperInvariant());
|
||||
}
|
||||
|
||||
return string.Join(' ', attributes);
|
||||
}
|
||||
if (VideoDoViTitle is not null)
|
||||
{
|
||||
attributes.Add(VideoDoViTitle);
|
||||
}
|
||||
else if (VideoRange != VideoRange.Unknown)
|
||||
{
|
||||
attributes.Add(VideoRange.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
var result = new StringBuilder(Title);
|
||||
foreach (var tag in attributes)
|
||||
{
|
||||
// Keep Tags that are not already in Title.
|
||||
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(" - ").Append(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
return string.Join(' ', attributes);
|
||||
}
|
||||
|
||||
case MediaStreamType.Subtitle:
|
||||
{
|
||||
var attributes = new List<string>();
|
||||
{
|
||||
var attributes = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(Language))
|
||||
{
|
||||
// Use pre-resolved localized language name, falling back to raw language code.
|
||||
attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
|
||||
}
|
||||
else
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedUndefined) ? "Und" : LocalizedUndefined);
|
||||
}
|
||||
|
||||
if (IsHearingImpaired == true)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired);
|
||||
}
|
||||
|
||||
if (IsDefault)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
|
||||
}
|
||||
|
||||
if (IsForced)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Codec))
|
||||
{
|
||||
attributes.Add(Codec.ToUpperInvariant());
|
||||
}
|
||||
|
||||
if (IsExternal)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
var result = new StringBuilder(Title);
|
||||
foreach (var tag in attributes)
|
||||
if (!string.IsNullOrEmpty(Language))
|
||||
{
|
||||
// Keep Tags that are not already in Title.
|
||||
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(" - ").Append(tag);
|
||||
}
|
||||
// Use pre-resolved localized language name, falling back to raw language code.
|
||||
attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
|
||||
}
|
||||
else
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedUndefined) ? "Und" : LocalizedUndefined);
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
if (IsHearingImpaired == true)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired);
|
||||
}
|
||||
|
||||
return string.Join(" - ", attributes);
|
||||
}
|
||||
if (IsDefault)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
|
||||
}
|
||||
|
||||
if (IsForced)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Codec))
|
||||
{
|
||||
attributes.Add(Codec.ToUpperInvariant());
|
||||
}
|
||||
|
||||
if (IsExternal)
|
||||
{
|
||||
attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
var result = new StringBuilder(Title);
|
||||
foreach (var tag in attributes)
|
||||
{
|
||||
// Keep Tags that are not already in Title.
|
||||
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(" - ").Append(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
return string.Join(" - ", attributes);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
@@ -499,6 +506,12 @@ namespace MediaBrowser.Model.Entities
|
||||
/// <value><c>true</c> if this instance is for the hearing impaired; otherwise, <c>false</c>.</value>
|
||||
public bool IsHearingImpaired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this instance is original.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if this instance is original; otherwise, <c>false</c>.</value>
|
||||
public bool IsOriginal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the height.
|
||||
/// </summary>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Model</PackageId>
|
||||
<VersionPrefix>10.12.0</VersionPrefix>
|
||||
<VersionPrefix>12.0.0</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace MediaBrowser.Model.Session
|
||||
VideoResolutionNotSupported = 1 << 8,
|
||||
VideoBitDepthNotSupported = 1 << 9,
|
||||
VideoFramerateNotSupported = 1 << 10,
|
||||
VideoRotationNotSupported = 1 << 27,
|
||||
RefFramesNotSupported = 1 << 11,
|
||||
AnamorphicVideoNotSupported = 1 << 12,
|
||||
InterlacedVideoNotSupported = 1 << 13,
|
||||
|
||||
@@ -1023,6 +1023,11 @@ namespace MediaBrowser.Providers.Manager
|
||||
target.OriginalTitle = source.OriginalTitle;
|
||||
}
|
||||
|
||||
if (replaceData || string.IsNullOrEmpty(target.OriginalLanguage))
|
||||
{
|
||||
target.OriginalLanguage = source.OriginalLanguage;
|
||||
}
|
||||
|
||||
if (replaceData || !target.CommunityRating.HasValue)
|
||||
{
|
||||
target.CommunityRating = source.CommunityRating;
|
||||
|
||||
@@ -218,12 +218,12 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
return Array.Empty<ExternalPathParserResult>();
|
||||
}
|
||||
|
||||
var files = directoryService.GetFilePaths(folder, clearCache, true).ToList();
|
||||
var files = directoryService.GetFilePaths(folder, clearCache).ToList();
|
||||
files.Remove(video.Path);
|
||||
var internalMetadataPath = video.GetInternalMetadataPath();
|
||||
if (_fileSystem.DirectoryExists(internalMetadataPath))
|
||||
{
|
||||
files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true));
|
||||
files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache));
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
@@ -270,12 +270,12 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
}
|
||||
|
||||
string folder = audio.ContainingFolderPath;
|
||||
var files = directoryService.GetFilePaths(folder, clearCache, true).ToList();
|
||||
var files = directoryService.GetFilePaths(folder, clearCache).ToList();
|
||||
files.Remove(audio.Path);
|
||||
var internalMetadataPath = audio.GetInternalMetadataPath();
|
||||
if (_fileSystem.DirectoryExists(internalMetadataPath))
|
||||
{
|
||||
files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true));
|
||||
files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache));
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
@@ -17,6 +18,18 @@ public class ImdbExternalUrlProvider : IExternalUrlProvider
|
||||
public IEnumerable<string> GetExternalUrls(BaseItem item)
|
||||
{
|
||||
var baseUrl = "https://www.imdb.com/";
|
||||
|
||||
if (item is Season season)
|
||||
{
|
||||
if (season.Series?.TryGetProviderId(MetadataProvider.Imdb, out var seriesImdbId) == true
|
||||
&& season.IndexNumber.HasValue)
|
||||
{
|
||||
yield return baseUrl + $"title/{seriesImdbId}/episodes/?season={season.IndexNumber.Value}";
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (item.TryGetProviderId(MetadataProvider.Imdb, out var externalId))
|
||||
{
|
||||
if (item is Person)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user