mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-04 14:58:36 +01:00
Compare commits
162 Commits
explicit-l
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39958ad9e5 | ||
|
|
7bde1ac224 | ||
|
|
143aee7e9e | ||
|
|
8c65dfefa1 | ||
|
|
869d8d3abc | ||
|
|
8d0534195d | ||
|
|
36af7fa7bf | ||
|
|
4b4b4cd94d | ||
|
|
f2b358a3c8 | ||
|
|
95600752a3 | ||
|
|
7249b5744c | ||
|
|
d8b0034a50 | ||
|
|
ed379a1882 | ||
|
|
984e67c067 | ||
|
|
eece62a90b | ||
|
|
e5c34e7096 | ||
|
|
9526083523 | ||
|
|
c4fb0285fc | ||
|
|
620b7a2495 | ||
|
|
ac9aa273ab | ||
|
|
72360ba292 | ||
|
|
706000cfce | ||
|
|
734145ab98 | ||
|
|
89d32a9525 | ||
|
|
19a35a6159 | ||
|
|
8743c22551 | ||
|
|
d1ba366f97 | ||
|
|
999de06d6b | ||
|
|
6435600a9c | ||
|
|
e52e448c30 | ||
|
|
f1137a9587 | ||
|
|
6de99306ec | ||
|
|
03ff69a6e1 | ||
|
|
94d0f7b1ac | ||
|
|
e83a7e62f2 | ||
|
|
445c6c9448 | ||
|
|
5f3189af41 | ||
|
|
b278dcf475 | ||
|
|
f7d80ae9e6 | ||
|
|
a023b9c88d | ||
|
|
40f35f6094 | ||
|
|
2b6fc19842 | ||
|
|
8c29098c8a | ||
|
|
758ee0af76 | ||
|
|
2e19c247ef | ||
|
|
511f90d6d3 | ||
|
|
1ae45519d0 | ||
|
|
586fa01e46 | ||
|
|
2ac0edc052 | ||
|
|
b37ebec5f6 | ||
|
|
938c043596 | ||
|
|
46a53d0605 | ||
|
|
97f88743b8 | ||
|
|
2c62d40f0d | ||
|
|
dca3cc74b7 | ||
|
|
be095f85ab | ||
|
|
f51c63e244 | ||
|
|
cc678383c9 | ||
|
|
ba0720a555 | ||
|
|
417df3df57 | ||
|
|
169e48ac00 | ||
|
|
b2aa80ce5c | ||
|
|
ff365dae34 | ||
|
|
52aebfb7d3 | ||
|
|
66ea1b50e6 | ||
|
|
3f656ade7a | ||
|
|
8bf0d372c6 | ||
|
|
202d7b5829 | ||
|
|
352e4f3aba | ||
|
|
c5f6d00c94 | ||
|
|
e8d1d94436 | ||
|
|
50dc37065b | ||
|
|
7e88b18192 | ||
|
|
89e914c7f1 | ||
|
|
1932ac4765 | ||
|
|
ec33c74ec4 | ||
|
|
2184ed1b16 | ||
|
|
d3907afde7 | ||
|
|
e12d933531 | ||
|
|
c0ba29d917 | ||
|
|
d1fd81c382 | ||
|
|
e038045494 | ||
|
|
e1691e649e | ||
|
|
8d28497d29 | ||
|
|
fddd4e7e6b | ||
|
|
0581cd6610 | ||
|
|
0f1732e5f5 | ||
|
|
41c2d51d8c | ||
|
|
29b2361857 | ||
|
|
ce867f9834 | ||
|
|
4034bf9d7e | ||
|
|
3d2658fa43 | ||
|
|
61b19688ff | ||
|
|
e8d72bf6a3 | ||
|
|
348b14f7b7 | ||
|
|
fda49a5a49 | ||
|
|
55c00d76bb | ||
|
|
519d2113eb | ||
|
|
f34f6b6941 | ||
|
|
6864e108b8 | ||
|
|
09ba04662a | ||
|
|
9cd2418095 | ||
|
|
b6a96513de | ||
|
|
ca57166e95 | ||
|
|
33496c1693 | ||
|
|
b65daeca0b | ||
|
|
286cc6d720 | ||
|
|
aa4f09c799 | ||
|
|
afd3c0d9f3 | ||
|
|
5597d8e1a7 | ||
|
|
0166362258 | ||
|
|
58c330b63d | ||
|
|
be71295693 | ||
|
|
8cd3090cee | ||
|
|
7bf08daeec | ||
|
|
290463fe7b | ||
|
|
1b2d9c100a | ||
|
|
caa05c1bf2 | ||
|
|
a37ead86df | ||
|
|
e65aff8bc6 | ||
|
|
9734494eb6 | ||
|
|
d41e302418 | ||
|
|
80ba517294 | ||
|
|
95d08b264f | ||
|
|
893a849f28 | ||
|
|
673f617994 | ||
|
|
644327eb76 | ||
|
|
10662e75e4 | ||
|
|
a2b1936e73 | ||
|
|
2df546af6d | ||
|
|
338b480217 | ||
|
|
2943bb6fdd | ||
|
|
94edcbd2d1 | ||
|
|
a8d1cdefac | ||
|
|
a518160a6f | ||
|
|
b56de6493f | ||
|
|
093cfc3f3b | ||
|
|
49775b1f6a | ||
|
|
22d593b8e9 | ||
|
|
2cb7fb52d2 | ||
|
|
8433b6d8a4 | ||
|
|
32d2414de0 | ||
|
|
317a3a47c3 | ||
|
|
845b8cdc8f | ||
|
|
c86f6439c5 | ||
|
|
559e0088e5 | ||
|
|
adaca95590 | ||
|
|
09a1c31fa3 | ||
|
|
e4b82025b8 | ||
|
|
78e3702cb0 | ||
|
|
01b20d3b75 | ||
|
|
156761405e | ||
|
|
1805f2259f | ||
|
|
4c587776d6 | ||
|
|
8379b4634a | ||
|
|
9470439cfa | ||
|
|
18096e48e0 | ||
|
|
f2d0ac7b28 | ||
|
|
2ccf08f547 | ||
|
|
1e27f460fe | ||
|
|
01ae62aa49 | ||
|
|
d2df6adc16 |
4
.github/workflows/ci-compat.yml
vendored
4
.github/workflows/ci-compat.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: ABI Compatibility
|
name: ABI Compatibility
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||||
|
|
||||||
name: ABI - Difference
|
name: ABI - Difference
|
||||||
if: ${{ github.event_name == 'pull_request_target' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- abi-head
|
- abi-head
|
||||||
|
|||||||
6
.github/workflows/ci-openapi.yml
vendored
6
.github/workflows/ci-openapi.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
- master
|
- master
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request_target:
|
pull_request:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||||
|
|
||||||
name: OpenAPI - Difference
|
name: OpenAPI - Difference
|
||||||
if: ${{ github.event_name == 'pull_request_target' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- openapi-head
|
- openapi-head
|
||||||
@@ -148,7 +148,7 @@ jobs:
|
|||||||
|
|
||||||
publish-unstable:
|
publish-unstable:
|
||||||
name: OpenAPI - Publish Unstable Spec
|
name: OpenAPI - Publish Unstable Spec
|
||||||
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- openapi-head
|
- openapi-head
|
||||||
|
|||||||
2
.github/workflows/commands.yml
vendored
2
.github/workflows/commands.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
types:
|
types:
|
||||||
- created
|
- created
|
||||||
- edited
|
- edited
|
||||||
pull_request_target:
|
pull_request:
|
||||||
types:
|
types:
|
||||||
- labeled
|
- labeled
|
||||||
- synchronize
|
- synchronize
|
||||||
|
|||||||
2
.github/workflows/project-automation.yml
vendored
2
.github/workflows/project-automation.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request_target:
|
pull_request:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|||||||
4
.github/workflows/pull-request-conflict.yml
vendored
4
.github/workflows/pull-request-conflict.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request_target:
|
pull_request:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Apply label
|
- name: Apply label
|
||||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
|
||||||
with:
|
with:
|
||||||
dirtyLabel: 'merge conflict'
|
dirtyLabel: 'merge conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -277,3 +277,7 @@ apiclient/generated
|
|||||||
|
|
||||||
# Omnisharp crash logs
|
# Omnisharp crash logs
|
||||||
mono_crash.*.json
|
mono_crash.*.json
|
||||||
|
|
||||||
|
# Devcontainer temp files
|
||||||
|
.devcontainer/devcontainer-lock.json
|
||||||
|
dotnet/
|
||||||
|
|||||||
@@ -207,6 +207,7 @@
|
|||||||
- [TokerX](https://github.com/TokerX)
|
- [TokerX](https://github.com/TokerX)
|
||||||
- [GeneMarks](https://github.com/GeneMarks)
|
- [GeneMarks](https://github.com/GeneMarks)
|
||||||
- [martenumberto](https://github.com/martenumberto)
|
- [martenumberto](https://github.com/martenumberto)
|
||||||
|
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
|
||||||
|
|
||||||
# Emby Contributors
|
# Emby Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Naming</PackageId>
|
<PackageId>Jellyfin.Naming</PackageId>
|
||||||
<VersionPrefix>10.11.4</VersionPrefix>
|
<VersionPrefix>10.11.10</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1051,16 +1051,16 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
// 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 foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||||
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
|
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
.Where(e => e.Value.Length > 0)
|
|
||||||
.Select(i =>
|
dto.ArtistItems = hasArtist.Artists
|
||||||
{
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
return new NameGuidPair
|
.Distinct()
|
||||||
{
|
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
|
||||||
Name = i.Key,
|
? new NameGuidPair { Name = name, Id = artists[0].Id }
|
||||||
Id = i.Value.First().Id
|
: null)
|
||||||
};
|
.Where(item => item is not null)
|
||||||
}).Where(i => i is not null).ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||||
@@ -1085,31 +1085,16 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
// })
|
// })
|
||||||
// .ToList();
|
// .ToList();
|
||||||
|
|
||||||
|
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
|
|
||||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||||
// .Except(foundArtists, new DistinctNameComparer())
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
.Select(i =>
|
.Distinct()
|
||||||
{
|
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
|
||||||
// This should not be necessary but we're seeing some cases of it
|
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
|
||||||
if (string.IsNullOrEmpty(i))
|
: null)
|
||||||
{
|
.Where(item => item is not null)
|
||||||
return null;
|
.ToArray();
|
||||||
}
|
|
||||||
|
|
||||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
|
||||||
{
|
|
||||||
EnableImages = false
|
|
||||||
});
|
|
||||||
if (artist is not null)
|
|
||||||
{
|
|
||||||
return new NameGuidPair
|
|
||||||
{
|
|
||||||
Name = artist.Name,
|
|
||||||
Id = artist.Id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}).Where(i => i is not null).ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add video info
|
// Add video info
|
||||||
|
|||||||
@@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||||
|
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||||
foreach (var i in _tempIgnoredPaths.Keys)
|
foreach (var i in _tempIgnoredPaths.Keys)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -267,8 +267,11 @@ namespace Emby.Server.Implementations.Images
|
|||||||
{
|
{
|
||||||
var image = item.GetImageInfo(type, 0);
|
var image = item.GetImageInfo(type, 0);
|
||||||
|
|
||||||
if (image is not null)
|
if (image is null)
|
||||||
{
|
{
|
||||||
|
return GetItemsWithImages(item).Count is not 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (!image.IsLocalFile)
|
if (!image.IsLocalFile)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -283,7 +286,6 @@ namespace Emby.Server.Implementations.Images
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images
|
|||||||
|
|
||||||
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
|
||||||
|
{
|
||||||
|
var age = DateTime.UtcNow - image.DateModified;
|
||||||
|
return age.TotalDays > 7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
"**/lost+found",
|
"**/lost+found",
|
||||||
"**/subs/**",
|
"**/subs/**",
|
||||||
"**/subs",
|
"**/subs",
|
||||||
|
"**/.snapshots/**",
|
||||||
|
"**/.snapshots",
|
||||||
|
"**/.snapshot/**",
|
||||||
|
"**/.snapshot",
|
||||||
|
|
||||||
// Trickplay files
|
// Trickplay files
|
||||||
"**/*.trickplay",
|
"**/*.trickplay",
|
||||||
|
|||||||
@@ -2202,6 +2202,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||||
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
||||||
{
|
{
|
||||||
if (item.IsFileProtocol)
|
if (item.IsFileProtocol)
|
||||||
@@ -3195,19 +3201,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||||
|
|
||||||
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
CreateShortcut(virtualFolderPath, pathInfo);
|
||||||
|
|
||||||
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
|
||||||
|
|
||||||
while (File.Exists(lnk))
|
|
||||||
{
|
|
||||||
shortcutFilename += "1";
|
|
||||||
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
|
||||||
}
|
|
||||||
|
|
||||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
|
||||||
|
|
||||||
RemoveContentTypeOverrides(path);
|
|
||||||
|
|
||||||
if (saveLibraryOptions)
|
if (saveLibraryOptions)
|
||||||
{
|
{
|
||||||
@@ -3372,5 +3366,24 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo)
|
||||||
|
{
|
||||||
|
var path = pathInfo.Path;
|
||||||
|
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||||
|
|
||||||
|
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
||||||
|
|
||||||
|
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||||
|
|
||||||
|
while (File.Exists(lnk))
|
||||||
|
{
|
||||||
|
shortcutFilename += "1";
|
||||||
|
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||||
|
RemoveContentTypeOverrides(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Library;
|
namespace Emby.Server.Implementations.Library;
|
||||||
|
|
||||||
@@ -14,18 +16,22 @@ namespace Emby.Server.Implementations.Library;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class PathManager : IPathManager
|
public class PathManager : IPathManager
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<PathManager> _logger;
|
||||||
private readonly IServerConfigurationManager _config;
|
private readonly IServerConfigurationManager _config;
|
||||||
private readonly IApplicationPaths _appPaths;
|
private readonly IApplicationPaths _appPaths;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PathManager"/> class.
|
/// Initializes a new instance of the <see cref="PathManager"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
/// <param name="config">The server configuration manager.</param>
|
/// <param name="config">The server configuration manager.</param>
|
||||||
/// <param name="appPaths">The application paths.</param>
|
/// <param name="appPaths">The application paths.</param>
|
||||||
public PathManager(
|
public PathManager(
|
||||||
|
ILogger<PathManager> logger,
|
||||||
IServerConfigurationManager config,
|
IServerConfigurationManager config,
|
||||||
IApplicationPaths appPaths)
|
IApplicationPaths appPaths)
|
||||||
{
|
{
|
||||||
|
_logger = logger;
|
||||||
_config = config;
|
_config = config;
|
||||||
_appPaths = appPaths;
|
_appPaths = appPaths;
|
||||||
}
|
}
|
||||||
@@ -35,9 +41,16 @@ public class PathManager : IPathManager
|
|||||||
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GetAttachmentPath(string mediaSourceId, string fileName)
|
public string? GetAttachmentPath(string mediaSourceId, string fileName)
|
||||||
{
|
{
|
||||||
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName);
|
var safeName = PathHelper.GetSafeLeafFileName(fileName);
|
||||||
|
if (safeName is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), safeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -236,12 +236,16 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UserItemData? GetUserData(User user, BaseItem item)
|
public UserItemData GetUserData(User user, BaseItem item)
|
||||||
{
|
{
|
||||||
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||||
|
return _cache.GetOrAdd(
|
||||||
|
cacheKey,
|
||||||
|
(k, i) => i.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
||||||
{
|
{
|
||||||
Key = item.GetUserDataKeys()[0],
|
Key = i.GetUserDataKeys()[0],
|
||||||
};
|
},
|
||||||
|
item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
|
|
||||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private List<CultureDto> _cultures = [];
|
private List<CultureDto> _cultures = [];
|
||||||
|
|
||||||
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
||||||
@@ -161,6 +162,7 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_cultureCache.Clear();
|
||||||
_cultures = list;
|
_cultures = list;
|
||||||
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
@@ -169,20 +171,31 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public CultureDto? FindLanguageInfo(string language)
|
public CultureDto? FindLanguageInfo(string language)
|
||||||
{
|
{
|
||||||
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
if (string.IsNullOrEmpty(language))
|
||||||
for (var i = 0; i < _cultures.Count; i++)
|
|
||||||
{
|
{
|
||||||
var culture = _cultures[i];
|
return null;
|
||||||
if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
}
|
||||||
|| language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase)
|
return _cultureCache.GetOrAdd(
|
||||||
|| language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
language,
|
||||||
|
static (lang, cultures) =>
|
||||||
|
{
|
||||||
|
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
||||||
|
for (var i = 0; i < cultures.Count; i++)
|
||||||
|
{
|
||||||
|
var culture = cultures[i];
|
||||||
|
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return culture;
|
return culture;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return default;
|
return null;
|
||||||
|
},
|
||||||
|
_cultures);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -311,15 +324,19 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fall back to server default language for ratings check
|
// Fall back to server default language for ratings check
|
||||||
// If it has no ratings, use the US ratings
|
var ratingsDictionary = GetParentalRatingsDictionary();
|
||||||
var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
|
|
||||||
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
||||||
{
|
{
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't find anything, check all ratings systems
|
// If we don't find anything, check all ratings systems, starting with US
|
||||||
|
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
|
||||||
|
{
|
||||||
|
return usValue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var dictionary in _allParentalRatings.Values)
|
foreach (var dictionary in _allParentalRatings.Values)
|
||||||
{
|
{
|
||||||
if (dictionary.TryGetValue(rating, out var value))
|
if (dictionary.TryGetValue(rating, out var value))
|
||||||
|
|||||||
@@ -271,9 +271,9 @@ namespace Emby.Server.Implementations.Session
|
|||||||
user.LastActivityDate = activityDate;
|
user.LastActivityDate = activityDate;
|
||||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (DbUpdateConcurrencyException e)
|
catch (DbUpdateConcurrencyException)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(e, "Error updating user's last activity date.");
|
_logger.LogDebug("Error updating user's last activity date due to concurrency conflict. This is an expected event.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1175,7 +1175,8 @@ namespace Emby.Server.Implementations.Session
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
/// <inheritdoc />
|
||||||
|
public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
||||||
{
|
{
|
||||||
return new SessionInfoDto
|
return new SessionInfoDto
|
||||||
{
|
{
|
||||||
@@ -2042,7 +2043,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
{
|
{
|
||||||
CheckDisposed();
|
CheckDisposed();
|
||||||
|
|
||||||
var adminUserIds = _userManager.Users
|
var adminUserIds = _userManager.GetUsers()
|
||||||
.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
|
.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
|
||||||
.Select(i => i.Id)
|
.Select(i => i.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
||||||
return Array.Empty<PackageInfo>();
|
return Array.Empty<PackageInfo>();
|
||||||
}
|
}
|
||||||
|
catch (NotSupportedException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
|
||||||
|
return Array.Empty<PackageInfo>();
|
||||||
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
||||||
|
|||||||
@@ -92,18 +92,18 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[ProducesAudioFile]
|
[ProducesAudioFile]
|
||||||
public async Task<ActionResult> GetAudioStream(
|
public async Task<ActionResult> GetAudioStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -114,7 +114,7 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -133,8 +133,8 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -259,18 +259,18 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[ProducesAudioFile]
|
[ProducesAudioFile]
|
||||||
public async Task<ActionResult> GetAudioStreamByContainer(
|
public async Task<ActionResult> GetAudioStreamByContainer(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -281,7 +281,7 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -300,8 +300,8 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
|
|||||||
@@ -167,18 +167,18 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[ProducesPlaylistFile]
|
[ProducesPlaylistFile]
|
||||||
public async Task<ActionResult> GetLiveHlsStream(
|
public async Task<ActionResult> GetLiveHlsStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -189,7 +189,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -208,8 +208,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -416,12 +416,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery, Required] string mediaSourceId,
|
[FromQuery, Required] string mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -432,7 +432,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -453,8 +453,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -592,12 +592,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery, Required] string mediaSourceId,
|
[FromQuery, Required] string mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -609,7 +609,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -628,8 +628,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -762,12 +762,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -778,7 +778,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -799,8 +799,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -934,12 +934,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -951,7 +951,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -970,8 +970,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1107,7 +1107,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] string playlistId,
|
[FromRoute, Required] string playlistId,
|
||||||
[FromRoute, Required] int segmentId,
|
[FromRoute, Required] int segmentId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery, Required] long runtimeTicks,
|
[FromQuery, Required] long runtimeTicks,
|
||||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
@@ -1115,12 +1115,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -1131,7 +1131,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -1152,8 +1152,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1292,7 +1292,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] string playlistId,
|
[FromRoute, Required] string playlistId,
|
||||||
[FromRoute, Required] int segmentId,
|
[FromRoute, Required] int segmentId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery, Required] long runtimeTicks,
|
[FromQuery, Required] long runtimeTicks,
|
||||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
@@ -1300,12 +1300,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -1317,7 +1317,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -1336,8 +1336,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1421,10 +1421,20 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
cancellationTokenSource.Token)
|
cancellationTokenSource.Token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
||||||
|
double fps = state.TargetFramerate ?? 0.0f;
|
||||||
|
int segmentLength = state.SegmentLength * 1000;
|
||||||
|
|
||||||
|
// If video is transcoded and framerate is fractional (i.e. 23.976), we need to slightly adjust segment length
|
||||||
|
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && Math.Abs(fps - Math.Floor(fps + 0.001f)) > 0.001)
|
||||||
|
{
|
||||||
|
double nearestIntFramerate = Math.Ceiling(fps);
|
||||||
|
segmentLength = (int)Math.Ceiling(segmentLength * (nearestIntFramerate / fps));
|
||||||
|
}
|
||||||
|
|
||||||
var request = new CreateMainPlaylistRequest(
|
var request = new CreateMainPlaylistRequest(
|
||||||
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
||||||
state.MediaPath,
|
state.MediaPath,
|
||||||
state.SegmentLength * 1000,
|
segmentLength,
|
||||||
state.RunTimeTicks ?? 0,
|
state.RunTimeTicks ?? 0,
|
||||||
state.Request.SegmentContainer ?? string.Empty,
|
state.Request.SegmentContainer ?? string.Empty,
|
||||||
"hls1/main/",
|
"hls1/main/",
|
||||||
@@ -1839,8 +1849,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (isActualOutputVideoCodecHevc)
|
if (isActualOutputVideoCodecHevc)
|
||||||
{
|
{
|
||||||
// Prefer dvh1 to dvhe
|
// Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
|
||||||
args += " -tag:v:0 dvh1 -strict -2";
|
var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
|
||||||
|
args += $" -tag:v:0 {codecTag} -strict -2";
|
||||||
}
|
}
|
||||||
else if (isActualOutputVideoCodecAv1)
|
else if (isActualOutputVideoCodecAv1)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (item is IHasAlbumArtist hasAlbumArtists)
|
if (item is IHasAlbumArtist hasAlbumArtists)
|
||||||
{
|
{
|
||||||
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name);
|
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +426,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (item is IHasArtist hasArtists)
|
if (item is IHasArtist hasArtists)
|
||||||
{
|
{
|
||||||
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name);
|
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LibraryOptions options = item.GetLibraryOptions();
|
||||||
|
foreach (var mediaPath in request.LibraryOptions!.PathInfos)
|
||||||
|
{
|
||||||
|
if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_libraryManager.CreateShortcut(item.Path, mediaPath);
|
||||||
|
}
|
||||||
|
|
||||||
item.UpdateLibraryOptions(request.LibraryOptions);
|
item.UpdateLibraryOptions(request.LibraryOptions);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpPost("Tuners/{tunerId}/Reset")]
|
[HttpPost("Tuners/{tunerId}/Reset")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
|
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
|
||||||
{
|
{
|
||||||
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
|
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
|
||||||
@@ -983,7 +983,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created tuner host returned.</response>
|
/// <response code="200">Created tuner host returned.</response>
|
||||||
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
|
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
|
||||||
[HttpPost("TunerHosts")]
|
[HttpPost("TunerHosts")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
|
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
|
||||||
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
|
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
|
||||||
@@ -995,7 +995,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="204">Tuner host deleted.</response>
|
/// <response code="204">Tuner host deleted.</response>
|
||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpDelete("TunerHosts")]
|
[HttpDelete("TunerHosts")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
||||||
{
|
{
|
||||||
@@ -1028,7 +1028,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created listings provider returned.</response>
|
/// <response code="200">Created listings provider returned.</response>
|
||||||
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
|
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
|
||||||
[HttpPost("ListingProviders")]
|
[HttpPost("ListingProviders")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
|
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
|
||||||
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
|
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
|
||||||
@@ -1054,7 +1054,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="204">Listing provider deleted.</response>
|
/// <response code="204">Listing provider deleted.</response>
|
||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpDelete("ListingProviders")]
|
[HttpDelete("ListingProviders")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public ActionResult DeleteListingProvider([FromQuery] string? id)
|
public ActionResult DeleteListingProvider([FromQuery] string? id)
|
||||||
{
|
{
|
||||||
@@ -1087,7 +1087,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Available countries returned.</response>
|
/// <response code="200">Available countries returned.</response>
|
||||||
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
|
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
|
||||||
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
|
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
|
||||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesFile(MediaTypeNames.Application.Json)]
|
[ProducesFile(MediaTypeNames.Application.Json)]
|
||||||
public async Task<ActionResult> GetSchedulesDirectCountries()
|
public async Task<ActionResult> GetSchedulesDirectCountries()
|
||||||
@@ -1108,7 +1108,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Channel mapping options returned.</response>
|
/// <response code="200">Channel mapping options returned.</response>
|
||||||
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
|
||||||
[HttpGet("ChannelMappingOptions")]
|
[HttpGet("ChannelMappingOptions")]
|
||||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
|
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
|
||||||
=> _listingsManager.GetChannelMappingOptions(providerId);
|
=> _listingsManager.GetChannelMappingOptions(providerId);
|
||||||
@@ -1120,7 +1120,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created channel mapping returned.</response>
|
/// <response code="200">Created channel mapping returned.</response>
|
||||||
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
|
||||||
[HttpPost("ChannelMappings")]
|
[HttpPost("ChannelMappings")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
|
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
|
||||||
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
|
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
|
||||||
@@ -1144,7 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
|
||||||
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
|
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
|
||||||
[HttpGet("Tuners/Discover")]
|
[HttpGet("Tuners/Discover")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
|
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
|
||||||
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
|
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
|
||||||
@@ -1192,7 +1192,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public ActionResult GetLiveStreamFile(
|
public ActionResult GetLiveStreamFile(
|
||||||
[FromRoute, Required] string streamId,
|
[FromRoute, Required] string streamId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container)
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container)
|
||||||
{
|
{
|
||||||
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
|
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
|
||||||
if (liveStreamInfo is null)
|
if (liveStreamInfo is null)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
using System;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Api.Constants;
|
using Jellyfin.Api.Constants;
|
||||||
using Jellyfin.Api.Models.StartupDtos;
|
using Jellyfin.Api.Models.StartupDtos;
|
||||||
@@ -111,7 +111,7 @@ public class StartupController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
// TODO: Remove this method when startup wizard no longer requires an existing user.
|
// TODO: Remove this method when startup wizard no longer requires an existing user.
|
||||||
await _userManager.InitializeAsync().ConfigureAwait(false);
|
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
|
return new StartupUserDto
|
||||||
{
|
{
|
||||||
Name = user.Username
|
Name = user.Username
|
||||||
@@ -131,22 +131,29 @@ public class StartupController : BaseJellyfinApiController
|
|||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
|
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))
|
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
|
||||||
{
|
{
|
||||||
return BadRequest("Password must not be empty");
|
return BadRequest("Password must not be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startupUserDto.Name is not null)
|
|
||||||
{
|
|
||||||
user.Username = startupUserDto.Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||||
|
|
||||||
|
#pragma warning disable CA1309 // Use ordinal string comparison
|
||||||
|
if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
#pragma warning restore CA1309 // Use ordinal string comparison
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(startupUserDto.Password))
|
if (!string.IsNullOrEmpty(startupUserDto.Password))
|
||||||
{
|
{
|
||||||
await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
|
await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public class SyncPlayController : BaseJellyfinApiController
|
|||||||
[FromBody, Required] NewGroupRequestDto requestData)
|
[FromBody, Required] NewGroupRequestDto requestData)
|
||||||
{
|
{
|
||||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||||
var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
|
var syncPlayRequest = new NewGroupRequest(requestData.GroupName.Trim());
|
||||||
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ public class UniversalAudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] Guid? userId,
|
[FromQuery] Guid? userId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] int? transcodingAudioChannels,
|
[FromQuery] int? transcodingAudioChannels,
|
||||||
[FromQuery] int? maxStreamingBitrate,
|
[FromQuery] int? maxStreamingBitrate,
|
||||||
[FromQuery] int? audioBitRate,
|
[FromQuery] int? audioBitRate,
|
||||||
[FromQuery] long? startTimeTicks,
|
[FromQuery] long? startTimeTicks,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? transcodingContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer,
|
||||||
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
||||||
[FromQuery] int? maxAudioSampleRate,
|
[FromQuery] int? maxAudioSampleRate,
|
||||||
[FromQuery] int? maxAudioBitDepth,
|
[FromQuery] int? maxAudioBitDepth,
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
|
|
||||||
if (request.ResetPassword)
|
if (request.ResetPassword)
|
||||||
{
|
{
|
||||||
await _userManager.ResetPassword(user).ConfigureAwait(false);
|
await _userManager.ResetPassword(user.Id).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -306,7 +306,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();
|
var currentToken = User.GetToken();
|
||||||
|
|
||||||
@@ -392,7 +392,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
|
|
||||||
if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
|
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);
|
await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false);
|
||||||
@@ -448,7 +448,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
// If removing admin access
|
// If removing admin access
|
||||||
if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
|
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.");
|
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
|
||||||
}
|
}
|
||||||
@@ -463,7 +463,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
// If disabling
|
// If disabling
|
||||||
if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
|
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.");
|
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
// no need to authenticate password for new user
|
// no need to authenticate password for new user
|
||||||
if (request.Password is not null)
|
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());
|
var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString());
|
||||||
@@ -620,7 +620,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
|
|
||||||
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
|
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
|
||||||
{
|
{
|
||||||
var users = _userManager.Users;
|
var users = _userManager.GetUsers();
|
||||||
|
|
||||||
if (isDisabled.HasValue)
|
if (isDisabled.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -315,18 +315,18 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public async Task<ActionResult> GetVideoStream(
|
public async Task<ActionResult> GetVideoStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -337,7 +337,7 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -358,8 +358,8 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -556,18 +556,18 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public Task<ActionResult> GetVideoStreamByContainer(
|
public Task<ActionResult> GetVideoStreamByContainer(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery] string? deviceProfileId,
|
[FromQuery] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -578,7 +578,7 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -599,8 +599,8 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public class DynamicHlsHelper
|
|||||||
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
||||||
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
|
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
||||||
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
||||||
}
|
}
|
||||||
@@ -173,10 +173,21 @@ public class DynamicHlsHelper
|
|||||||
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main stream
|
// Video rotation metadata is only supported in fMP4 remuxing
|
||||||
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
if (state.VideoStream is not null
|
||||||
|
&& state.VideoRequest is not null
|
||||||
|
&& (state.VideoStream?.Rotation ?? 0) != 0
|
||||||
|
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
|
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||||
|
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
queryString += "&AllowVideoStreamCopy=false";
|
||||||
|
}
|
||||||
|
|
||||||
playlistUrl += queryString;
|
// Main stream
|
||||||
|
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||||
|
var playlistUrl = baseUrl + queryString;
|
||||||
|
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
|
|
||||||
var subtitleStreams = state.MediaSource
|
var subtitleStreams = state.MediaSource
|
||||||
.MediaStreams
|
.MediaStreams
|
||||||
@@ -198,37 +209,36 @@ public class DynamicHlsHelper
|
|||||||
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video rotation metadata is only supported in fMP4 remuxing
|
|
||||||
if (state.VideoStream is not null
|
|
||||||
&& state.VideoRequest is not null
|
|
||||||
&& (state.VideoStream?.Rotation ?? 0) != 0
|
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
|
||||||
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
|
||||||
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
playlistUrl += "&AllowVideoStreamCopy=false";
|
|
||||||
}
|
|
||||||
|
|
||||||
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
||||||
|
|
||||||
if (state.VideoStream is not null && state.VideoRequest is not null)
|
if (state.VideoStream is not null && state.VideoRequest is not null)
|
||||||
{
|
{
|
||||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||||
|
|
||||||
// Provide SDR HEVC entrance for backward compatibility.
|
// Provide AV1 and HEVC SDR entrances for backward compatibility.
|
||||||
if (encodingOptions.AllowHevcEncoding
|
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
|
||||||
&& !encodingOptions.AllowAv1Encoding
|
{
|
||||||
|
var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
|
||||||
|
&& string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
|
||||||
|
&& string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
|
||||||
|
|
||||||
|
if (isEncodingAllowed
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
&& state.VideoStream.VideoRange == VideoRange.HDR
|
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
|
// Force AV1 and HEVC Main Profile and disable video stream copy.
|
||||||
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
|
state.OutputVideoCodec = sdrVideoCodec;
|
||||||
{
|
|
||||||
// Force HEVC Main Profile and disable video stream copy.
|
var sdrPlaylistQuery = playlistQuery;
|
||||||
state.OutputVideoCodec = "hevc";
|
sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
|
||||||
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
|
sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
|
||||||
sdrVideoUrl += "&AllowVideoStreamCopy=false";
|
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||||
|
|
||||||
|
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||||
|
|
||||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||||
@@ -238,12 +248,30 @@ public class DynamicHlsHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provide H.264 SDR entrance for backward compatibility.
|
||||||
|
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
|
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||||
|
{
|
||||||
|
// Force H.264 and disable video stream copy.
|
||||||
|
state.OutputVideoCodec = "h264";
|
||||||
|
|
||||||
|
var sdrPlaylistQuery = playlistQuery;
|
||||||
|
sdrPlaylistQuery["VideoCodec"] = "h264";
|
||||||
|
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||||
|
|
||||||
|
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||||
|
|
||||||
|
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||||
|
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||||
|
|
||||||
|
// Restore the video codec
|
||||||
|
state.OutputVideoCodec = "copy";
|
||||||
|
}
|
||||||
|
|
||||||
// Provide Level 5.0 entrance for backward compatibility.
|
// Provide Level 5.0 entrance for backward compatibility.
|
||||||
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
||||||
// but in fact it is capable of playing videos up to Level 6.1.
|
// but in fact it is capable of playing videos up to Level 6.1.
|
||||||
if (encodingOptions.AllowHevcEncoding
|
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
&& !encodingOptions.AllowAv1Encoding
|
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
|
||||||
&& state.VideoStream.Level.HasValue
|
&& state.VideoStream.Level.HasValue
|
||||||
&& state.VideoStream.Level > 150
|
&& state.VideoStream.Level > 150
|
||||||
&& state.VideoStream.VideoRange == VideoRange.SDR
|
&& state.VideoStream.VideoRange == VideoRange.SDR
|
||||||
@@ -273,12 +301,15 @@ public class DynamicHlsHelper
|
|||||||
var variation = GetBitrateVariation(totalBitrate);
|
var variation = GetBitrateVariation(totalBitrate);
|
||||||
|
|
||||||
var newBitrate = totalBitrate - variation;
|
var newBitrate = totalBitrate - variation;
|
||||||
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
var variantQuery = playlistQuery;
|
||||||
|
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||||
|
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||||
|
|
||||||
variation *= 2;
|
variation *= 2;
|
||||||
newBitrate = totalBitrate - variation;
|
newBitrate = totalBitrate - variation;
|
||||||
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||||
|
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,23 +894,6 @@ public class DynamicHlsHelper
|
|||||||
return variation;
|
return variation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
|
|
||||||
{
|
|
||||||
return url.Replace(
|
|
||||||
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
|
|
||||||
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
|
|
||||||
StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
|
|
||||||
{
|
|
||||||
string profileStr = codec + "-profile=";
|
|
||||||
return url.Replace(
|
|
||||||
profileStr + oldValue,
|
|
||||||
profileStr + newValue,
|
|
||||||
StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
||||||
{
|
{
|
||||||
var oldPlaylist = playlist.ToString();
|
var oldPlaylist = playlist.ToString();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Api.Extensions;
|
using Jellyfin.Api.Extensions;
|
||||||
@@ -17,9 +18,7 @@ using MediaBrowser.Controller.MediaEncoding;
|
|||||||
using MediaBrowser.Controller.Streaming;
|
using MediaBrowser.Controller.Streaming;
|
||||||
using MediaBrowser.Model.Dlna;
|
using MediaBrowser.Model.Dlna;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Helpers;
|
namespace Jellyfin.Api.Helpers;
|
||||||
@@ -422,14 +421,18 @@ public static class StreamingHelpers
|
|||||||
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null && IsValidCodecName(val))
|
||||||
{
|
{
|
||||||
videoRequest.VideoCodec = val;
|
videoRequest.VideoCodec = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
|
if (IsValidCodecName(val))
|
||||||
|
{
|
||||||
request.AudioCodec = val;
|
request.AudioCodec = val;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 6:
|
case 6:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null)
|
||||||
@@ -483,7 +486,7 @@ public static class StreamingHelpers
|
|||||||
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
|
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
|
||||||
break;
|
break;
|
||||||
case 15:
|
case 15:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null && Regex.IsMatch(val, EncodingHelper.LevelValidationRegexStr))
|
||||||
{
|
{
|
||||||
videoRequest.Level = val;
|
videoRequest.Level = val;
|
||||||
}
|
}
|
||||||
@@ -504,7 +507,7 @@ public static class StreamingHelpers
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case 18:
|
case 18:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null && IsValidCodecName(val))
|
||||||
{
|
{
|
||||||
videoRequest.Profile = val;
|
videoRequest.Profile = val;
|
||||||
}
|
}
|
||||||
@@ -563,7 +566,11 @@ public static class StreamingHelpers
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case 30:
|
case 30:
|
||||||
|
if (IsValidCodecName(val))
|
||||||
|
{
|
||||||
request.SubtitleCodec = val;
|
request.SubtitleCodec = val;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 31:
|
case 31:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null)
|
||||||
@@ -586,6 +593,11 @@ public static class StreamingHelpers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidCodecName(string val)
|
||||||
|
{
|
||||||
|
return EncodingHelper.ContainerValidationRegex().IsMatch(val);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the container into its file extension.
|
/// Parses the container into its file extension.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Models.SyncPlayDtos;
|
namespace Jellyfin.Api.Models.SyncPlayDtos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -17,5 +19,6 @@ public class NewGroupRequestDto
|
|||||||
/// Gets or sets the group name.
|
/// Gets or sets the group name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The name of the new group.</value>
|
/// <value>The name of the new group.</value>
|
||||||
|
[StringLength(200, ErrorMessage = "Group name must not exceed 200 characters.")]
|
||||||
public string GroupName { get; set; }
|
public string GroupName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using MediaBrowser.Controller.Authentication;
|
|||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Session;
|
using MediaBrowser.Model.Session;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ namespace Jellyfin.Api.WebSocketListeners;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Class SessionInfoWebSocketListener.
|
/// Class SessionInfoWebSocketListener.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState>
|
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
|
||||||
{
|
{
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -52,24 +53,26 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
|
|||||||
/// Gets the data to send.
|
/// Gets the data to send.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>Task{SystemInfo}.</returns>
|
/// <returns>Task{SystemInfo}.</returns>
|
||||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSend()
|
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
|
||||||
{
|
{
|
||||||
return Task.FromResult(_sessionManager.Sessions);
|
return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection)
|
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSendForConnection(IWebSocketConnection connection)
|
||||||
{
|
{
|
||||||
|
var sessions = _sessionManager.Sessions;
|
||||||
|
|
||||||
// For non-admin users, filter the sessions to only include their own sessions
|
// For non-admin users, filter the sessions to only include their own sessions
|
||||||
if (connection.AuthorizationInfo?.User is not null &&
|
if (connection.AuthorizationInfo?.User is not null &&
|
||||||
!connection.AuthorizationInfo.IsApiKey &&
|
!connection.AuthorizationInfo.IsApiKey &&
|
||||||
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
|
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
|
||||||
{
|
{
|
||||||
var userId = connection.AuthorizationInfo.User.Id;
|
var userId = connection.AuthorizationInfo.User.Id;
|
||||||
return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId)));
|
sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(_sessionManager.Sessions);
|
return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Data</PackageId>
|
<PackageId>Jellyfin.Data</PackageId>
|
||||||
<VersionPrefix>10.11.4</VersionPrefix>
|
<VersionPrefix>10.11.10</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ public static class UserEntityExtensions
|
|||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, false));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
|
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
|
||||||
|
|||||||
@@ -118,15 +118,21 @@ public class BackupService : IBackupService
|
|||||||
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
|
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
|
||||||
}
|
}
|
||||||
|
|
||||||
void CopyDirectory(string source, string target)
|
void CopyDirectory(string source, string target, string[]? exclude = null)
|
||||||
{
|
{
|
||||||
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
||||||
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
||||||
|
var excludePaths = exclude?.Select(e => $"{source}/{e}/").ToArray();
|
||||||
foreach (var item in zipArchive.Entries)
|
foreach (var item in zipArchive.Entries)
|
||||||
{
|
{
|
||||||
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
||||||
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
||||||
|
|
||||||
|
if (excludePaths is not null && excludePaths.Any(e => item.FullName.StartsWith(e, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
||||||
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|
||||||
|| Path.EndsInDirectorySeparator(item.FullName))
|
|| Path.EndsInDirectorySeparator(item.FullName))
|
||||||
@@ -142,8 +148,10 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
||||||
CopyDirectory("Data", _applicationPaths.DataPath);
|
CopyDirectory("Data", _applicationPaths.DataPath, exclude: ["metadata", "metadata-default"]);
|
||||||
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
||||||
|
CopyDirectory("Data/metadata", _applicationPaths.InternalMetadataPath);
|
||||||
|
CopyDirectory("Data/metadata-default", _applicationPaths.DefaultInternalMetadataPath);
|
||||||
|
|
||||||
if (manifest.Options.Database)
|
if (manifest.Options.Database)
|
||||||
{
|
{
|
||||||
@@ -403,6 +411,15 @@ public class BackupService : IBackupService
|
|||||||
if (backupOptions.Metadata)
|
if (backupOptions.Metadata)
|
||||||
{
|
{
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
|
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
|
||||||
|
|
||||||
|
// If a custom metadata path is configured, the default location may still contain data.
|
||||||
|
if (!string.Equals(
|
||||||
|
Path.GetFullPath(_applicationPaths.DefaultInternalMetadataPath),
|
||||||
|
Path.GetFullPath(_applicationPaths.InternalMetadataPath),
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.DefaultInternalMetadataPath), Path.Combine("Data", "metadata-default"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
|
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ using MediaBrowser.Controller.LiveTv;
|
|||||||
using MediaBrowser.Controller.Persistence;
|
using MediaBrowser.Controller.Persistence;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.LiveTv;
|
using MediaBrowser.Model.LiveTv;
|
||||||
using MediaBrowser.Model.Querying;
|
using MediaBrowser.Model.Querying;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -69,6 +70,7 @@ public sealed class BaseItemRepository
|
|||||||
private readonly IItemTypeLookup _itemTypeLookup;
|
private readonly IItemTypeLookup _itemTypeLookup;
|
||||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
private readonly ILogger<BaseItemRepository> _logger;
|
private readonly ILogger<BaseItemRepository> _logger;
|
||||||
|
private readonly ILocalizationManager _localizationManager;
|
||||||
|
|
||||||
private static readonly IReadOnlyList<ItemValueType> _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist];
|
private static readonly IReadOnlyList<ItemValueType> _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist];
|
||||||
private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist];
|
private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist];
|
||||||
@@ -85,18 +87,21 @@ public sealed class BaseItemRepository
|
|||||||
/// <param name="itemTypeLookup">The static type lookup.</param>
|
/// <param name="itemTypeLookup">The static type lookup.</param>
|
||||||
/// <param name="serverConfigurationManager">The server Configuration manager.</param>
|
/// <param name="serverConfigurationManager">The server Configuration manager.</param>
|
||||||
/// <param name="logger">System logger.</param>
|
/// <param name="logger">System logger.</param>
|
||||||
|
/// <param name="localizationManager">Localization manager.</param>
|
||||||
public BaseItemRepository(
|
public BaseItemRepository(
|
||||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
IServerApplicationHost appHost,
|
IServerApplicationHost appHost,
|
||||||
IItemTypeLookup itemTypeLookup,
|
IItemTypeLookup itemTypeLookup,
|
||||||
IServerConfigurationManager serverConfigurationManager,
|
IServerConfigurationManager serverConfigurationManager,
|
||||||
ILogger<BaseItemRepository> logger)
|
ILogger<BaseItemRepository> logger,
|
||||||
|
ILocalizationManager localizationManager)
|
||||||
{
|
{
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
_itemTypeLookup = itemTypeLookup;
|
_itemTypeLookup = itemTypeLookup;
|
||||||
_serverConfigurationManager = serverConfigurationManager;
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_localizationManager = localizationManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -295,6 +300,25 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||||
|
|
||||||
|
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
|
||||||
|
if (hasRandomSort)
|
||||||
|
{
|
||||||
|
var orderedIds = dbQuery.Select(e => e.Id).ToList();
|
||||||
|
if (orderedIds.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
|
||||||
|
.AsEnumerable()
|
||||||
|
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||||
|
.Where(dto => dto is not null)
|
||||||
|
.ToDictionary(i => i!.Id);
|
||||||
|
|
||||||
|
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
|
||||||
|
}
|
||||||
|
|
||||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||||
|
|
||||||
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||||
@@ -617,7 +641,6 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
var ids = tuples.Select(f => f.Item.Id).ToArray();
|
var ids = tuples.Select(f => f.Item.Id).ToArray();
|
||||||
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
|
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
|
||||||
var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
|
|
||||||
|
|
||||||
foreach (var item in tuples)
|
foreach (var item in tuples)
|
||||||
{
|
{
|
||||||
@@ -651,19 +674,6 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
context.SaveChanges();
|
context.SaveChanges();
|
||||||
|
|
||||||
foreach (var item in newItems)
|
|
||||||
{
|
|
||||||
// reattach old userData entries
|
|
||||||
var userKeys = item.UserDataKey.ToArray();
|
|
||||||
var retentionDate = (DateTime?)null;
|
|
||||||
context.UserData
|
|
||||||
.Where(e => e.ItemId == PlaceholderId)
|
|
||||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
|
||||||
.ExecuteUpdate(e => e
|
|
||||||
.SetProperty(f => f.ItemId, item.Item.Id)
|
|
||||||
.SetProperty(f => f.RetentionDate, retentionDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemValueMaps = tuples
|
var itemValueMaps = tuples
|
||||||
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
@@ -759,6 +769,43 @@ public sealed class BaseItemRepository
|
|||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (transaction.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var userKeys = item.GetUserDataKeys().ToArray();
|
||||||
|
var retentionDate = (DateTime?)null;
|
||||||
|
|
||||||
|
await dbContext.UserData
|
||||||
|
.Where(e => e.ItemId == PlaceholderId)
|
||||||
|
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
e => e
|
||||||
|
.SetProperty(f => f.ItemId, item.Id)
|
||||||
|
.SetProperty(f => f.RetentionDate, retentionDate),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Rehydrate the cached userdata
|
||||||
|
item.UserData = await dbContext.UserData
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.ItemId == item.Id)
|
||||||
|
.ToArrayAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public BaseItemDto? RetrieveItem(Guid id)
|
public BaseItemDto? RetrieveItem(Guid id)
|
||||||
{
|
{
|
||||||
@@ -866,7 +913,7 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
|
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
|
||||||
dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
|
dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
|
||||||
dto.Studios = entity.Studios?.Split('|') ?? [];
|
dto.Studios = entity.Studios?.Split('|') ?? [];
|
||||||
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
||||||
|
|
||||||
@@ -1028,7 +1075,7 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
|
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
|
||||||
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null;
|
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null;
|
||||||
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
|
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
|
||||||
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
|
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
|
||||||
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
|
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
|
||||||
@@ -1558,29 +1605,36 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
|
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
|
||||||
|
|
||||||
|
// When searching, prioritize by match quality: exact match > prefix match > contains
|
||||||
|
if (hasSearch)
|
||||||
|
{
|
||||||
|
orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!));
|
||||||
|
}
|
||||||
|
|
||||||
var firstOrdering = orderBy.FirstOrDefault();
|
var firstOrdering = orderBy.FirstOrDefault();
|
||||||
if (firstOrdering != default)
|
if (firstOrdering != default)
|
||||||
{
|
{
|
||||||
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
|
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
|
||||||
if (firstOrdering.SortOrder == SortOrder.Ascending)
|
if (orderedQuery is null)
|
||||||
{
|
{
|
||||||
orderedQuery = query.OrderBy(expression);
|
// No search relevance ordering, start fresh
|
||||||
|
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||||
|
? query.OrderBy(expression)
|
||||||
|
: query.OrderByDescending(expression);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
orderedQuery = query.OrderByDescending(expression);
|
// Search relevance ordering already applied, chain with ThenBy
|
||||||
|
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||||
|
? orderedQuery.ThenBy(expression)
|
||||||
|
: orderedQuery.ThenByDescending(expression);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
|
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
|
||||||
{
|
{
|
||||||
if (firstOrdering.SortOrder is SortOrder.Ascending)
|
orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending
|
||||||
{
|
? orderedQuery.ThenBy(e => e.Name)
|
||||||
orderedQuery = orderedQuery.ThenBy(e => e.Name);
|
: orderedQuery.ThenByDescending(e => e.Name);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
orderedQuery = orderedQuery.ThenByDescending(e => e.Name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2247,27 +2301,43 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
|
||||||
|
{
|
||||||
|
var lang = _localizationManager.FindLanguageInfo(filter.HasNoAudioTrackWithLanguage);
|
||||||
|
if (lang is not null)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && f.Language == filter.HasNoAudioTrackWithLanguage));
|
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
|
||||||
|
{
|
||||||
|
var lang = _localizationManager.FindLanguageInfo(filter.HasNoInternalSubtitleTrackWithLanguage);
|
||||||
|
if (lang is not null)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && f.Language == filter.HasNoInternalSubtitleTrackWithLanguage));
|
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
|
||||||
|
{
|
||||||
|
var lang = _localizationManager.FindLanguageInfo(filter.HasNoExternalSubtitleTrackWithLanguage);
|
||||||
|
if (lang is not null)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && f.Language == filter.HasNoExternalSubtitleTrackWithLanguage));
|
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
|
||||||
|
{
|
||||||
|
var lang = _localizationManager.FindLanguageInfo(filter.HasNoSubtitleTrackWithLanguage);
|
||||||
|
if (lang is not null)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.Language == filter.HasNoSubtitleTrackWithLanguage));
|
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasSubtitles.HasValue)
|
if (filter.HasSubtitles.HasValue)
|
||||||
@@ -2447,35 +2517,24 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.ExcludeInheritedTags.Length > 0)
|
if (filter.ExcludeInheritedTags.Length > 0)
|
||||||
{
|
{
|
||||||
|
var excludedTags = filter.ExcludeInheritedTags;
|
||||||
baseQuery = baseQuery.Where(e =>
|
baseQuery = baseQuery.Where(e =>
|
||||||
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
|
||||||
&& (e.Type != _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode] || !e.SeriesId.HasValue ||
|
&& (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
|
||||||
!context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.IncludeInheritedTags.Length > 0)
|
if (filter.IncludeInheritedTags.Length > 0)
|
||||||
{
|
{
|
||||||
// For seasons and episodes, we also need to check the parent series' tags.
|
var includeTags = filter.IncludeInheritedTags;
|
||||||
if (includeTypes.Any(t => t == BaseItemKind.Episode || t == BaseItemKind.Season))
|
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
|
||||||
{
|
|
||||||
baseQuery = baseQuery.Where(e =>
|
baseQuery = baseQuery.Where(e =>
|
||||||
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
|
||||||
|| (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A playlist should be accessible to its owner regardless of allowed tags.
|
// For seasons and episodes, we also need to check the parent series' tags.
|
||||||
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
|
|| (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
|
||||||
{
|
|
||||||
baseQuery = baseQuery.Where(e =>
|
// A playlist should be accessible to its owner regardless of allowed tags
|
||||||
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
|
||||||
|| e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
|
|
||||||
// d ^^ this is stupid it hate this.
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
baseQuery = baseQuery.Where(e =>
|
|
||||||
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.SeriesStatuses.Length > 0)
|
if (filter.SeriesStatuses.Length > 0)
|
||||||
@@ -2629,6 +2688,21 @@ public sealed class BaseItemRepository
|
|||||||
.Where(e => artistNames.Contains(e.Name))
|
.Where(e => artistNames.Contains(e.Name))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray());
|
var lookup = artists
|
||||||
|
.GroupBy(e => e.Name!)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||||
|
|
||||||
|
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||||
|
foreach (var name in artistNames)
|
||||||
|
{
|
||||||
|
if (lookup.TryGetValue(name, out var artistArray))
|
||||||
|
{
|
||||||
|
result[name] = artistArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Linq.Expressions;
|
|||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -68,4 +69,30 @@ public static class OrderMapper
|
|||||||
_ => e => e.SortName
|
_ => e => e.SortName
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an expression to order search results by match quality.
|
||||||
|
/// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="searchTerm">The search term to match against.</param>
|
||||||
|
/// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
|
||||||
|
public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm)
|
||||||
|
{
|
||||||
|
var cleanSearchTerm = GetCleanValue(searchTerm);
|
||||||
|
var searchPrefix = cleanSearchTerm + " ";
|
||||||
|
return e =>
|
||||||
|
e.CleanName == cleanSearchTerm ? 0 :
|
||||||
|
e.CleanName!.StartsWith(searchPrefix) ? 1 :
|
||||||
|
e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCleanValue(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.RemoveDiacritics().ToLowerInvariant();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
var resetUser = userManager.GetUserByName(spr.UserName)
|
var resetUser = userManager.GetUserByName(spr.UserName)
|
||||||
?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found");
|
?? 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);
|
usersReset.Add(resetUser.Username);
|
||||||
File.Delete(resetFile);
|
File.Delete(resetFile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
#pragma warning disable CA1307
|
#pragma warning disable RS0030 // Do not use banned APIs
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using AsyncKeyedLock;
|
||||||
using Jellyfin.Data;
|
using Jellyfin.Data;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Data.Events;
|
using Jellyfin.Data.Events;
|
||||||
@@ -35,7 +36,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manages the creation and retrieval of <see cref="User"/> instances.
|
/// Manages the creation and retrieval of <see cref="User"/> instances.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class UserManager : IUserManager
|
public partial class UserManager : IUserManager, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
private readonly IEventManager _eventManager;
|
private readonly IEventManager _eventManager;
|
||||||
@@ -50,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
||||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
|
||||||
private readonly IDictionary<Guid, User> _users;
|
private readonly LockHelper _userLock = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
||||||
@@ -89,29 +90,28 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
|
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
|
||||||
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
|
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
|
||||||
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
|
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
|
||||||
|
|
||||||
_users = new ConcurrentDictionary<Guid, User>();
|
|
||||||
using var dbContext = _dbProvider.CreateDbContext();
|
|
||||||
foreach (var user in dbContext.Users
|
|
||||||
.AsSplitQuery()
|
|
||||||
.Include(user => user.Permissions)
|
|
||||||
.Include(user => user.Preferences)
|
|
||||||
.Include(user => user.AccessSchedules)
|
|
||||||
.Include(user => user.ProfileImage)
|
|
||||||
.AsEnumerable())
|
|
||||||
{
|
|
||||||
_users.Add(user.Id, user);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
|
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IEnumerable<User> Users => _users.Values;
|
public IEnumerable<User> GetUsers()
|
||||||
|
{
|
||||||
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
return UserQuery(dbContext)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <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 @
|
// 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
|
// In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
|
||||||
@@ -127,8 +127,28 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
throw new ArgumentException("Guid can't be empty", nameof(id));
|
throw new ArgumentException("Guid can't be empty", nameof(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
_users.TryGetValue(id, out var user);
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
return user;
|
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/>
|
/// <inheritdoc/>
|
||||||
@@ -139,29 +159,32 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
throw new ArgumentException("Invalid username", nameof(name));
|
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
|
||||||
|
return UserQuery(dbContext)
|
||||||
|
.FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant());
|
||||||
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task RenameUser(User user, string newName)
|
public async Task RenameUser(Guid userId, string oldName, string newName)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
|
||||||
|
|
||||||
ThrowIfInvalidUsername(newName);
|
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.");
|
throw new ArgumentException("The new and old names must be different.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.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 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
|
if (await dbContext.Users
|
||||||
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id))
|
.AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId)
|
||||||
.ConfigureAwait(false))
|
.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
throw new ArgumentException(string.Format(
|
throw new ArgumentException(string.Format(
|
||||||
@@ -169,13 +192,19 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
"A user with the name '{0}' already exists.",
|
"A user with the name '{0}' already exists.",
|
||||||
newName));
|
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
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
|
||||||
|
user = await UserQuery(dbContext)
|
||||||
|
.AsTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new ResourceNotFoundException(nameof(userId));
|
||||||
|
|
||||||
user.Username = newName;
|
user.Username = newName;
|
||||||
|
user.NormalizedUsername = newName.ToUpperInvariant();
|
||||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var eventArgs = new UserUpdatedEventArgs(user);
|
var eventArgs = new UserUpdatedEventArgs(user);
|
||||||
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
|
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
|
||||||
@@ -184,11 +213,61 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task UpdateUserAsync(User user)
|
public async Task UpdateUserAsync(User user)
|
||||||
|
{
|
||||||
|
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
// TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead.
|
||||||
|
var dbUser = await UserQuery(dbContext)
|
||||||
|
.AsTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == user.Id)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new ResourceNotFoundException(nameof(user.Id));
|
||||||
|
|
||||||
|
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
|
||||||
|
dbUser.Permissions.Clear();
|
||||||
|
foreach (var permission in user.Permissions)
|
||||||
|
{
|
||||||
|
dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser.Preferences.Clear();
|
||||||
|
foreach (var preference in user.Preferences)
|
||||||
|
{
|
||||||
|
dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser.AccessSchedules.Clear();
|
||||||
|
foreach (var accessSchedule in user.AccessSchedules)
|
||||||
|
{
|
||||||
|
dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.ProfileImage is null)
|
||||||
|
{
|
||||||
|
if (dbUser.ProfileImage is not null)
|
||||||
|
{
|
||||||
|
dbContext.Remove(dbUser.ProfileImage);
|
||||||
|
dbUser.ProfileImage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (dbUser.ProfileImage is null)
|
||||||
|
{
|
||||||
|
dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path)
|
||||||
|
{
|
||||||
|
LastModified = user.ProfileImage.LastModified
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dbUser.ProfileImage.Path = user.ProfileImage.Path;
|
||||||
|
dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,23 +297,26 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
{
|
{
|
||||||
ThrowIfInvalidUsername(name);
|
ThrowIfInvalidUsername(name);
|
||||||
|
|
||||||
if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
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
|
||||||
|
if (await dbContext.Users
|
||||||
|
.AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant())
|
||||||
|
.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
throw new ArgumentException(string.Format(
|
throw new ArgumentException(string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"A user with the name '{0}' already exists.",
|
"A user with the name '{0}' already exists.",
|
||||||
name));
|
name));
|
||||||
}
|
}
|
||||||
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
|
||||||
User newUser;
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
|
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
|
||||||
|
|
||||||
dbContext.Users.Add(newUser);
|
dbContext.Users.Add(newUser);
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
_users.Add(newUser.Id, newUser);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
|
await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
|
||||||
@@ -245,12 +327,24 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task DeleteUserAsync(Guid userId)
|
public async Task DeleteUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
if (!_users.TryGetValue(userId, out var user))
|
User? user;
|
||||||
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
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));
|
throw new ResourceNotFoundException(nameof(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_users.Count == 1)
|
var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false);
|
||||||
|
if (userCount == 1)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(string.Format(
|
throw new InvalidOperationException(string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
@@ -259,7 +353,9 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user.HasPermission(PermissionKind.IsAdministrator)
|
if (user.HasPermission(PermissionKind.IsAdministrator)
|
||||||
&& Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
&& await dbContext.Users
|
||||||
|
.CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value))
|
||||||
|
.ConfigureAwait(false) == 1)
|
||||||
{
|
{
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
string.Format(
|
string.Format(
|
||||||
@@ -269,38 +365,46 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
nameof(userId));
|
nameof(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
dbContext.Users.Attach(user);
|
|
||||||
dbContext.Users.Remove(user);
|
dbContext.Users.Remove(user);
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_users.Remove(userId);
|
|
||||||
|
|
||||||
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
|
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public Task ResetPassword(User user)
|
public Task ResetPassword(Guid userId)
|
||||||
{
|
{
|
||||||
return ChangePassword(user, string.Empty);
|
return ChangePassword(userId, string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task ChangePassword(User user, string newPassword)
|
public async Task ChangePassword(Guid userId, string newPassword)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
User dbUser = null!;
|
||||||
if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
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));
|
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
|
||||||
}
|
}
|
||||||
|
|
||||||
await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
|
await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false);
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false);
|
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -403,11 +507,31 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
throw new ArgumentNullException(nameof(username));
|
throw new ArgumentNullException(nameof(username));
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
|
bool success;
|
||||||
|
var user = GetUserByName(username);
|
||||||
|
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user;
|
||||||
|
}
|
||||||
|
|
||||||
var authResult = await AuthenticateLocalUser(username, password, user)
|
var authResult = await AuthenticateLocalUser(username, password, user)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var authenticationProvider = authResult.AuthenticationProvider;
|
var authenticationProvider = authResult.AuthenticationProvider;
|
||||||
var success = authResult.Success;
|
success = authResult.Success;
|
||||||
|
|
||||||
|
if (success && user is not null)
|
||||||
|
{
|
||||||
|
// refresh the user if the auth provider might have updated it in the auth method.
|
||||||
|
// this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
|
||||||
|
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
@@ -422,11 +546,16 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
// Search the database for the user again
|
// Search the database for the user again
|
||||||
// the authentication provider might have created it
|
// the authentication provider might have created it
|
||||||
user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
|
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
user = await UserQuery(dbContext)
|
||||||
|
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
|
||||||
|
|
||||||
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
||||||
{
|
{
|
||||||
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
||||||
|
user = await UserQuery(dbContext)
|
||||||
|
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
|
||||||
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,8 +566,10 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
user.AuthenticationProviderId = providerId;
|
await dbContext.Users
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,21 +616,48 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
{
|
{
|
||||||
if (isUserSession)
|
if (isUserSession)
|
||||||
{
|
{
|
||||||
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
|
var date = DateTime.UtcNow;
|
||||||
|
await dbContext.Users
|
||||||
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e
|
||||||
|
.SetProperty(f => f.LastActivityDate, date)
|
||||||
|
.SetProperty(f => f.LastLoginDate, date))
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.InvalidLoginAttemptCount = 0;
|
await dbContext.Users
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0))
|
||||||
|
.ConfigureAwait(false);
|
||||||
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
|
user.InvalidLoginAttemptCount++;
|
||||||
|
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
|
||||||
|
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
||||||
|
{
|
||||||
|
user.SetPermission(PermissionKind.IsDisabled, true);
|
||||||
|
await dbContext.SaveChangesAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
|
||||||
|
user.Username,
|
||||||
|
user.InvalidLoginAttemptCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.Users
|
||||||
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||||
user.Username,
|
user.Username,
|
||||||
remoteEndPoint);
|
remoteEndPoint);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return success ? user : null;
|
return success ? user : null;
|
||||||
}
|
}
|
||||||
@@ -542,7 +700,10 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
|
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
|
||||||
if (_users.Any())
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -555,9 +716,6 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
||||||
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
|
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
|
||||||
newUser.SetPermission(PermissionKind.IsAdministrator, true);
|
newUser.SetPermission(PermissionKind.IsAdministrator, true);
|
||||||
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
|
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
|
||||||
@@ -565,7 +723,6 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
dbContext.Users.Add(newUser);
|
dbContext.Users.Add(newUser);
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
_users.Add(newUser.Id, newUser);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,15 +758,14 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
|
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
|
||||||
|
{
|
||||||
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var user = dbContext.Users
|
var user = UserQuery(dbContext)
|
||||||
.Include(u => u.Permissions)
|
.AsTracking()
|
||||||
.Include(u => u.Preferences)
|
|
||||||
.Include(u => u.AccessSchedules)
|
|
||||||
.Include(u => u.ProfileImage)
|
|
||||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||||
?? throw new ArgumentException("No user exists with given Id!");
|
?? throw new ArgumentException("No user exists with given Id!");
|
||||||
|
|
||||||
@@ -638,22 +794,21 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
||||||
|
|
||||||
dbContext.Update(user);
|
dbContext.Update(user);
|
||||||
_users[user.Id] = user;
|
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
|
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
|
||||||
|
{
|
||||||
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var user = dbContext.Users
|
var user = UserQuery(dbContext)
|
||||||
.Include(u => u.Permissions)
|
.AsTracking()
|
||||||
.Include(u => u.Preferences)
|
|
||||||
.Include(u => u.AccessSchedules)
|
|
||||||
.Include(u => u.ProfileImage)
|
|
||||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||||
?? throw new ArgumentException("No user exists with given Id!");
|
?? throw new ArgumentException("No user exists with given Id!");
|
||||||
|
|
||||||
@@ -716,10 +871,10 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
||||||
|
|
||||||
dbContext.Update(user);
|
dbContext.Update(user);
|
||||||
_users[user.Id] = user;
|
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task ClearProfileImageAsync(User user)
|
public async Task ClearProfileImageAsync(User user)
|
||||||
@@ -729,6 +884,8 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||||
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
@@ -737,7 +894,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.ProfileImage = null;
|
user.ProfileImage = null;
|
||||||
_users[user.Id] = user;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void ThrowIfInvalidUsername(string name)
|
internal static void ThrowIfInvalidUsername(string name)
|
||||||
@@ -869,29 +1026,95 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task IncrementInvalidLoginAttemptCount(User user)
|
|
||||||
{
|
|
||||||
user.InvalidLoginAttemptCount++;
|
|
||||||
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
|
|
||||||
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
|
||||||
{
|
|
||||||
user.SetPermission(PermissionKind.IsDisabled, true);
|
|
||||||
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
|
|
||||||
user.Username,
|
|
||||||
user.InvalidLoginAttemptCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
|
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
|
||||||
{
|
{
|
||||||
dbContext.Users.Attach(user);
|
dbContext.Users.Attach(user);
|
||||||
dbContext.Entry(user).State = EntityState.Modified;
|
dbContext.Entry(user).State = EntityState.Modified;
|
||||||
_users[user.Id] = user;
|
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class LockHelper : IDisposable
|
||||||
|
{
|
||||||
|
private readonly AsyncKeyedLocker<Guid> _userLock = new();
|
||||||
|
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public static AsyncLocal<int> IsNestedLock { get; set; } = new();
|
||||||
|
|
||||||
|
public bool ShouldLock()
|
||||||
|
{
|
||||||
|
return IsNestedLock.Value == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<IDisposable> LockAsync(Guid key)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
var isNested = LockHelper.IsNestedLock.Value != 0;
|
||||||
|
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1;
|
||||||
|
if (isNested)
|
||||||
|
{
|
||||||
|
return new ValueTask<IDisposable>(new LockHandle { Parent = null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return AcquireLockAsync(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<IDisposable> AcquireLockAsync(Guid key)
|
||||||
|
{
|
||||||
|
var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false);
|
||||||
|
return new LockHandle { Parent = lockHandle };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_userLock.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LockHandle : IDisposable
|
||||||
|
{
|
||||||
|
public required IDisposable? Parent { get; init; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Parent?.Dispose();
|
||||||
|
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1;
|
||||||
|
|
||||||
|
if (LockHelper.IsNestedLock.Value < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Migrations.Routines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Part 2 Migration for NormalisedUsername.
|
||||||
|
/// </summary>
|
||||||
|
[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
|
||||||
|
#pragma warning disable SA1649 // File name should match first type name
|
||||||
|
public class UpdateNormalizedUsername : IAsyncMigrationRoutine
|
||||||
|
#pragma warning restore SA1649 // File name should match first type name
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _contextFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="contextFactory">Db Context factory.</param>
|
||||||
|
public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory)
|
||||||
|
{
|
||||||
|
_contextFactory = contextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
user.NormalizedUsername = user.Username.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Server.ServerSetupApp;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Migrations.Routines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Migration to fix broken library subtitle download languages.
|
||||||
|
/// </summary>
|
||||||
|
[JellyfinMigration("2026-02-06T20:00:00", nameof(FixLibrarySubtitleDownloadLanguages))]
|
||||||
|
internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
|
||||||
|
{
|
||||||
|
private readonly ILocalizationManager _localizationManager;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="FixLibrarySubtitleDownloadLanguages"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="localizationManager">The Localization manager.</param>
|
||||||
|
/// <param name="startupLogger">The startup logger for Startup UI integration.</param>
|
||||||
|
/// <param name="libraryManager">The Library manager.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public FixLibrarySubtitleDownloadLanguages(
|
||||||
|
ILocalizationManager localizationManager,
|
||||||
|
IStartupLogger<FixLibrarySubtitleDownloadLanguages> startupLogger,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
ILogger<FixLibrarySubtitleDownloadLanguages> logger)
|
||||||
|
{
|
||||||
|
_localizationManager = localizationManager;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_logger = startupLogger.With(logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task PerformAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting to fix library subtitle download languages.");
|
||||||
|
|
||||||
|
var virtualFolders = _libraryManager.GetVirtualFolders(false);
|
||||||
|
|
||||||
|
foreach (var virtualFolder in virtualFolders)
|
||||||
|
{
|
||||||
|
var options = virtualFolder.LibraryOptions;
|
||||||
|
if (options?.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some virtual folders don't have a proper item id.
|
||||||
|
if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
|
||||||
|
if (collectionFolder is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixedLanguages = new List<string>();
|
||||||
|
|
||||||
|
foreach (var language in options.SubtitleDownloadLanguages)
|
||||||
|
{
|
||||||
|
var foundLanguage = _localizationManager.FindLanguageInfo(language)?.ThreeLetterISOLanguageName;
|
||||||
|
if (foundLanguage is not null)
|
||||||
|
{
|
||||||
|
// Converted ISO 639-2/B to T (ger to deu)
|
||||||
|
if (!string.Equals(foundLanguage, language, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Converted '{Language}' to '{ResolvedLanguage}' in library '{LibraryName}'.", language, foundLanguage, virtualFolder.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixedLanguages.Contains(foundLanguage, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Language '{Language}' already exists for library '{LibraryName}'. Skipping duplicate.", foundLanguage, virtualFolder.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedLanguages.Add(foundLanguage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Could not resolve language '{Language}' in library '{LibraryName}'. Skipping.", language, virtualFolder.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.SubtitleDownloadLanguages = [.. fixedLanguages];
|
||||||
|
collectionFolder.UpdateLibraryOptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Library subtitle download languages fixed.");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -464,6 +464,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
SqliteConnection.ClearAllPools();
|
SqliteConnection.ClearAllPools();
|
||||||
|
|
||||||
|
using (var checkpointConnection = new SqliteConnection($"Filename={libraryDbPath}"))
|
||||||
|
{
|
||||||
|
checkpointConnection.Open();
|
||||||
|
using var cmd = checkpointConnection.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
|
||||||
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
|
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
|
||||||
File.Move(libraryDbPath, libraryDbPath + ".old", true);
|
File.Move(libraryDbPath, libraryDbPath + ".old", true);
|
||||||
}
|
}
|
||||||
@@ -1163,7 +1173,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
Item = null!,
|
Item = null!,
|
||||||
ProviderId = e[0],
|
ProviderId = e[0],
|
||||||
ProviderValue = string.Join('|', e.Skip(1))
|
ProviderValue = string.Join('|', e.Skip(1))
|
||||||
}).ToArray();
|
})
|
||||||
|
.DistinctBy(e => e.ProviderId)
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var imageInfos))
|
if (reader.TryGetString(index++, out var imageInfos))
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
|
var attachmentIndexName = attachmentIndex.ToString(CultureInfo.InvariantCulture);
|
||||||
|
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndexName)
|
||||||
|
?? _pathManager.GetAttachmentPath(itemIdString, attachmentIndexName)!;
|
||||||
if (File.Exists(newAttachmentPath))
|
if (File.Exists(newAttachmentPath))
|
||||||
{
|
{
|
||||||
File.Delete(oldAttachmentPath);
|
File.Delete(oldAttachmentPath);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Common</PackageId>
|
<PackageId>Jellyfin.Common</PackageId>
|
||||||
<VersionPrefix>10.11.4</VersionPrefix>
|
<VersionPrefix>10.11.10</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.ClientEvent
|
namespace MediaBrowser.Controller.ClientEvent
|
||||||
{
|
{
|
||||||
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
|
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
|
||||||
{
|
{
|
||||||
var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
|
var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
|
||||||
|
var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
|
||||||
|
var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
|
||||||
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
|
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
|
||||||
|
if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Path resolved to filename not in log directory");
|
||||||
|
}
|
||||||
|
|
||||||
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||||
await using (fileStream.ConfigureAwait(false))
|
await using (fileStream.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1172,11 +1172,18 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
info.Video3DFormat = video.Video3DFormat;
|
info.Video3DFormat = video.Video3DFormat;
|
||||||
info.Timestamp = video.Timestamp;
|
info.Timestamp = video.Timestamp;
|
||||||
|
|
||||||
if (video.IsShortcut)
|
if (video.IsShortcut && !string.IsNullOrEmpty(video.ShortcutPath))
|
||||||
|
{
|
||||||
|
var shortcutProtocol = MediaSourceManager.GetPathProtocol(video.ShortcutPath);
|
||||||
|
|
||||||
|
// Only allow remote shortcut paths — local file paths in .strm files
|
||||||
|
// could be used to read arbitrary files from the server.
|
||||||
|
if (shortcutProtocol != MediaProtocol.File)
|
||||||
{
|
{
|
||||||
info.IsRemote = true;
|
info.IsRemote = true;
|
||||||
info.Path = video.ShortcutPath;
|
info.Path = video.ShortcutPath;
|
||||||
info.Protocol = MediaSourceManager.GetPathProtocol(info.Path);
|
info.Protocol = shortcutProtocol;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(info.Container))
|
if (string.IsNullOrEmpty(info.Container))
|
||||||
@@ -2053,6 +2060,9 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
|
public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||||
=> await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false);
|
=> await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
public async Task ReattachUserDataAsync(CancellationToken cancellationToken) =>
|
||||||
|
await LibraryManager.ReattachUserDataAsync(this, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates that images within the item are still on the filesystem.
|
/// Validates that images within the item are still on the filesystem.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
// That's all the new and changed ones - now see if any have been removed and need cleanup
|
// That's all the new and changed ones - now see if any have been removed and need cleanup
|
||||||
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
|
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
|
||||||
var shouldRemove = !IsRoot || allowRemoveRoot;
|
var shouldRemove = !IsRoot || allowRemoveRoot;
|
||||||
|
var actuallyRemoved = new List<BaseItem>();
|
||||||
// If it's an AggregateFolder, don't remove
|
// If it's an AggregateFolder, don't remove
|
||||||
if (shouldRemove && itemsRemoved.Count > 0)
|
if (shouldRemove && itemsRemoved.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -467,6 +468,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
{
|
{
|
||||||
Logger.LogDebug("Removed item: {Path}", item.Path);
|
Logger.LogDebug("Removed item: {Path}", item.Path);
|
||||||
|
|
||||||
|
actuallyRemoved.Add(item);
|
||||||
item.SetParent(null);
|
item.SetParent(null);
|
||||||
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
|
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
|
||||||
}
|
}
|
||||||
@@ -477,6 +479,20 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
{
|
{
|
||||||
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After removing items, reattach any detached user data to remaining children
|
||||||
|
// that share the same user data keys (eg. same episode replaced with a new file).
|
||||||
|
if (actuallyRemoved.Count > 0)
|
||||||
|
{
|
||||||
|
var removedKeys = actuallyRemoved.SelectMany(i => i.GetUserDataKeys()).ToHashSet();
|
||||||
|
foreach (var child in validChildren)
|
||||||
|
{
|
||||||
|
if (child.GetUserDataKeys().Any(removedKeys.Contains))
|
||||||
|
{
|
||||||
|
await child.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -201,12 +201,17 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
|
|
||||||
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||||
{
|
{
|
||||||
|
if (series is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
|
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<BaseItem> GetEpisodes()
|
public List<BaseItem> GetEpisodes()
|
||||||
{
|
{
|
||||||
return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
|
return GetEpisodes(Series, null, null, new DtoOptions(true), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
|
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
query.AncestorWithPresentationUniqueKey = null;
|
query.AncestorWithPresentationUniqueKey = null;
|
||||||
query.SeriesPresentationUniqueKey = seriesKey;
|
query.SeriesPresentationUniqueKey = seriesKey;
|
||||||
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
||||||
query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) };
|
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||||
|
|
||||||
if (user is not null && !user.DisplayMissingEpisodes)
|
if (user is not null && !user.DisplayMissingEpisodes)
|
||||||
{
|
{
|
||||||
@@ -247,6 +247,10 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
|
|
||||||
query.AncestorWithPresentationUniqueKey = null;
|
query.AncestorWithPresentationUniqueKey = null;
|
||||||
query.SeriesPresentationUniqueKey = seriesKey;
|
query.SeriesPresentationUniqueKey = seriesKey;
|
||||||
|
if (query.OrderBy.Count == 0)
|
||||||
|
{
|
||||||
|
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||||
|
}
|
||||||
|
|
||||||
if (query.IncludeItemTypes.Length == 0)
|
if (query.IncludeItemTypes.Length == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ public interface IPathManager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="mediaSourceId">The media source id.</param>
|
/// <param name="mediaSourceId">The media source id.</param>
|
||||||
/// <param name="fileName">The attachmentFileName index.</param>
|
/// <param name="fileName">The attachmentFileName index.</param>
|
||||||
/// <returns>The absolute path.</returns>
|
/// <returns>The absolute path, or <c>null</c> if <paramref name="fileName"/> cannot be reduced to a safe leaf name.</returns>
|
||||||
public string GetAttachmentPath(string mediaSourceId, string fileName);
|
public string? GetAttachmentPath(string mediaSourceId, string fileName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the path to the attachment folder.
|
/// Gets the path to the attachment folder.
|
||||||
|
|||||||
@@ -281,6 +281,14 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// <returns>Returns a Task that can be awaited.</returns>
|
/// <returns>Returns a Task that can be awaited.</returns>
|
||||||
Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
|
Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reattaches the user data to the item.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
|
||||||
|
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the item.
|
/// Retrieves the item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -652,5 +660,12 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// This exists so plugins can trigger a library scan.
|
/// This exists so plugins can trigger a library scan.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
void QueueLibraryScan();
|
void QueueLibraryScan();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add mblink file for a media path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="virtualFolderPath">The path to the virtualfolder.</param>
|
||||||
|
/// <param name="pathInfo">The new virtualfolder.</param>
|
||||||
|
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the users.
|
/// Gets the users.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The users.</value>
|
/// <returns>The users.</returns>
|
||||||
IEnumerable<User> Users { get; }
|
IEnumerable<User> GetUsers();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the user ids.
|
/// Gets the user ids.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The users ids.</value>
|
/// <returns>The users ids.</returns>
|
||||||
IEnumerable<Guid> UsersIds { get; }
|
IEnumerable<Guid> GetUsersIds();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes the user manager and ensures that a user exists.
|
/// 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>
|
/// <exception cref="ArgumentException"><c>id</c> is an empty Guid.</exception>
|
||||||
User? GetUserById(Guid id);
|
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>
|
/// <summary>
|
||||||
/// Gets the name of the user by.
|
/// Gets the name of the user by.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -57,12 +63,13 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renames the user.
|
/// Renames the user.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="newName">The new name.</param>
|
||||||
/// <returns>Task.</returns>
|
/// <returns>Task.</returns>
|
||||||
/// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception>
|
/// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception>
|
||||||
/// <exception cref="ArgumentException">If the provided user doesn't exist.</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>
|
/// <summary>
|
||||||
/// Updates the user.
|
/// Updates the user.
|
||||||
@@ -92,17 +99,17 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resets the password.
|
/// Resets the password.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="userId">The users Id.</param>
|
||||||
/// <returns>Task.</returns>
|
/// <returns>Task.</returns>
|
||||||
Task ResetPassword(User user);
|
Task ResetPassword(Guid userId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Changes the password.
|
/// Changes the password.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="userId">The users id.</param>
|
||||||
/// <param name="newPassword">New password to use.</param>
|
/// <param name="newPassword">New password to use.</param>
|
||||||
/// <returns>Awaitable task.</returns>
|
/// <returns>Awaitable task.</returns>
|
||||||
Task ChangePassword(User user, string newPassword);
|
Task ChangePassword(Guid userId, string newPassword);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the user dto.
|
/// Gets the user dto.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Controller</PackageId>
|
<PackageId>Jellyfin.Controller</PackageId>
|
||||||
<VersionPrefix>10.11.4</VersionPrefix>
|
<VersionPrefix>10.11.10</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -33,18 +33,18 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
public partial class EncodingHelper
|
public partial class EncodingHelper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The codec validation regex.
|
/// The codec validation regex string.
|
||||||
/// This regular expression matches strings that consist of alphanumeric characters, hyphens,
|
/// This regular expression matches strings that consist of alphanumeric characters, hyphens,
|
||||||
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
|
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
|
||||||
/// This should matches all common valid codecs.
|
/// This should matches all common valid codecs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string ContainerValidationRegex = @"^[a-zA-Z0-9\-\._,|]{0,40}$";
|
public const string ContainerValidationRegexStr = @"^[a-zA-Z0-9\-\._,|]{0,40}$";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The level validation regex.
|
/// The level validation regex string.
|
||||||
/// This regular expression matches strings representing a double.
|
/// This regular expression matches strings representing a double.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string LevelValidationRegex = @"-?[0-9]+(?:\.[0-9]+)?";
|
public const string LevelValidationRegexStr = @"-?[0-9]+(?:\.[0-9]+)?";
|
||||||
|
|
||||||
private const string _defaultMjpegEncoder = "mjpeg";
|
private const string _defaultMjpegEncoder = "mjpeg";
|
||||||
|
|
||||||
@@ -85,8 +85,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1);
|
private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1);
|
||||||
private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0);
|
private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0);
|
||||||
private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1);
|
private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1);
|
||||||
|
private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0);
|
||||||
private static readonly Regex _containerValidationRegex = new(ContainerValidationRegex, RegexOptions.Compiled);
|
|
||||||
|
|
||||||
private static readonly string[] _videoProfilesH264 =
|
private static readonly string[] _videoProfilesH264 =
|
||||||
[
|
[
|
||||||
@@ -180,6 +179,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
RemoveHdr10Plus,
|
RemoveHdr10Plus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The codec validation regex.
|
||||||
|
/// This regular expression matches strings that consist of alphanumeric characters, hyphens,
|
||||||
|
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
|
||||||
|
/// This should matches all common valid codecs.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"^[a-zA-Z0-9\-\._,|]{0,40}$")]
|
||||||
|
public static partial Regex ContainerValidationRegex();
|
||||||
|
|
||||||
[GeneratedRegex(@"\s+")]
|
[GeneratedRegex(@"\s+")]
|
||||||
private static partial Regex WhiteSpaceRegex();
|
private static partial Regex WhiteSpaceRegex();
|
||||||
|
|
||||||
@@ -405,7 +413,9 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
|
|
||||||
return state.VideoStream.VideoRange == VideoRange.HDR
|
return state.VideoStream.VideoRange == VideoRange.HDR
|
||||||
&& IsDoviWithHdr10Bl(state.VideoStream);
|
&& (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|
||||||
|
|| IsHdr10Plus(state.VideoStream)
|
||||||
|
|| IsDoviWithHdr10Bl(state.VideoStream));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options)
|
private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options)
|
||||||
@@ -420,8 +430,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
// Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding.
|
// Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding.
|
||||||
// All other HDR formats working.
|
// All other HDR formats working.
|
||||||
return state.VideoStream.VideoRange == VideoRange.HDR
|
return state.VideoStream.VideoRange == VideoRange.HDR
|
||||||
&& (IsDoviWithHdr10Bl(state.VideoStream)
|
&& (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|
||||||
|| state.VideoStream.VideoRangeType is VideoRangeType.HLG);
|
|| IsHdr10Plus(state.VideoStream)
|
||||||
|
|| IsDoviWithHdr10Bl(state.VideoStream)
|
||||||
|
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
|
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
|
||||||
@@ -476,7 +488,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
return GetMjpegEncoder(state, encodingOptions);
|
return GetMjpegEncoder(state, encodingOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_containerValidationRegex.IsMatch(codec))
|
if (ContainerValidationRegex().IsMatch(codec))
|
||||||
{
|
{
|
||||||
return codec.ToLowerInvariant();
|
return codec.ToLowerInvariant();
|
||||||
}
|
}
|
||||||
@@ -517,7 +529,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|
|
||||||
public static string GetInputFormat(string container)
|
public static string GetInputFormat(string container)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(container) || !_containerValidationRegex.IsMatch(container))
|
if (string.IsNullOrEmpty(container) || !ContainerValidationRegex().IsMatch(container))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -735,7 +747,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
{
|
{
|
||||||
var codec = state.OutputAudioCodec;
|
var codec = state.OutputAudioCodec;
|
||||||
|
|
||||||
if (!_containerValidationRegex.IsMatch(codec))
|
if (!ContainerValidationRegex().IsMatch(codec))
|
||||||
{
|
{
|
||||||
codec = "aac";
|
codec = "aac";
|
||||||
}
|
}
|
||||||
@@ -1267,6 +1279,20 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use analyzeduration also for subtitle streams to improve resolution detection with streams inside MKS files
|
||||||
|
var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
|
||||||
|
if (!string.IsNullOrEmpty(analyzeDurationArgument))
|
||||||
|
{
|
||||||
|
arg.Append(' ').Append(analyzeDurationArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply probesize, too, if configured
|
||||||
|
var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
|
||||||
|
if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
|
||||||
|
{
|
||||||
|
arg.Append(' ').Append(ffmpegProbeSizeArgument);
|
||||||
|
}
|
||||||
|
|
||||||
// Also seek the external subtitles stream.
|
// Also seek the external subtitles stream.
|
||||||
var seekSubParam = GetFastSeekCommandLineParameter(state, options, segmentContainer);
|
var seekSubParam = GetFastSeekCommandLineParameter(state, options, segmentContainer);
|
||||||
if (!string.IsNullOrEmpty(seekSubParam))
|
if (!string.IsNullOrEmpty(seekSubParam))
|
||||||
@@ -1279,7 +1305,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
arg.Append(canvasArgs);
|
arg.Append(canvasArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
arg.Append(" -i file:\"").Append(subtitlePath).Append('\"');
|
arg.Append(" -i file:\"").Append(subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('\"');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.AudioStream is not null && state.AudioStream.IsExternal)
|
if (state.AudioStream is not null && state.AudioStream.IsExternal)
|
||||||
@@ -1291,7 +1317,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
arg.Append(' ').Append(seekAudioParam);
|
arg.Append(' ').Append(seekAudioParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"');
|
arg.Append(" -i \"").Append(state.AudioStream.Path.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable auto inserted SW scaler for HW decoders in case of changed resolution.
|
// Disable auto inserted SW scaler for HW decoders in case of changed resolution.
|
||||||
@@ -1552,14 +1578,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|
|
||||||
int bitrate = state.OutputVideoBitrate.Value;
|
int bitrate = state.OutputVideoBitrate.Value;
|
||||||
|
|
||||||
// Bit rate under 1000k is not allowed in h264_qsv
|
// Bit rate under 1000k is not allowed in h264_qsv.
|
||||||
if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
bitrate = Math.Max(bitrate, 1000);
|
bitrate = Math.Max(bitrate, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Currently use the same buffer size for all encoders
|
// Currently use the same buffer size for all non-QSV encoders.
|
||||||
int bufsize = bitrate * 2;
|
// Use long arithmetic to prevent int32 overflow for very high bitrate values.
|
||||||
|
int bufsize = (int)Math.Min((long)bitrate * 2, int.MaxValue);
|
||||||
|
|
||||||
if (string.Equals(videoCodec, "libsvtav1", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(videoCodec, "libsvtav1", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -1587,16 +1614,33 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
mbbrcOpt = " -mbbrc 1";
|
mbbrcOpt = " -mbbrc 1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some less powerful H.264 HW decoders require strict CPB size
|
||||||
|
// So bufsize optimizations should not be applied to them
|
||||||
|
int factor = 2;
|
||||||
|
var codec = state.ActualOutputVideoCodec;
|
||||||
|
var level = state.GetRequestedLevel(codec);
|
||||||
|
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& double.TryParse(level, CultureInfo.InvariantCulture, out double requestedLevel)
|
||||||
|
&& requestedLevel < 51)
|
||||||
|
{
|
||||||
|
factor = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation
|
// Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation
|
||||||
// Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes
|
// Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes
|
||||||
return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {bitrate + 1} -rc_init_occupancy {bitrate * 2} -bufsize {bitrate * 4}");
|
// Use long arithmetic and clamp to int.MaxValue to prevent int32 overflow
|
||||||
|
// (e.g. bitrate * 4 wraps to a negative value for bitrates above ~537 million)
|
||||||
|
int qsvMaxrate = (int)Math.Min((long)bitrate + 1, int.MaxValue);
|
||||||
|
int qsvInitOcc = (int)Math.Min((long)bitrate * 1 * factor, int.MaxValue);
|
||||||
|
int qsvBufsize = (int)Math.Min((long)bitrate * 2 * factor, int.MaxValue);
|
||||||
|
|
||||||
|
return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {qsvMaxrate} -rc_init_occupancy {qsvInitOcc} -bufsize {qsvBufsize}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
|
||||||
|| string.Equals(videoCodec, "av1_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}");
|
return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1768,8 +1812,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|
|
||||||
public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
|
public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
|
||||||
{
|
{
|
||||||
if (double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel))
|
if (!double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel))
|
||||||
{
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
// Transcode to level 5.3 (15) and lower for maximum compatibility.
|
// Transcode to level 5.3 (15) and lower for maximum compatibility.
|
||||||
@@ -1801,7 +1848,6 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
return "51";
|
return "51";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return level;
|
return level;
|
||||||
}
|
}
|
||||||
@@ -2189,12 +2235,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var level = state.GetRequestedLevel(targetVideoCodec);
|
var level = NormalizeTranscodingLevel(state, state.GetRequestedLevel(targetVideoCodec));
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(level))
|
if (!string.IsNullOrEmpty(level))
|
||||||
{
|
{
|
||||||
level = NormalizeTranscodingLevel(state, level);
|
|
||||||
|
|
||||||
// libx264, QSV, AMF can adjust the given level to match the output.
|
// libx264, QSV, AMF can adjust the given level to match the output.
|
||||||
if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
|
|| string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -6359,6 +6403,19 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block unsupported H.264 Hi422P and Hi444PP profiles, which can be encoded with 4:2:0 pixel format
|
||||||
|
if (string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& ((videoStream.Profile?.Contains("4:2:2", StringComparison.OrdinalIgnoreCase) ?? false)
|
||||||
|
|| (videoStream.Profile?.Contains("4:4:4", StringComparison.OrdinalIgnoreCase) ?? false)))
|
||||||
|
{
|
||||||
|
// VideoToolbox on Apple Silicon has H.264 Hi444PP and theoretically also has Hi422P
|
||||||
|
if (!(hardwareAccelerationType == HardwareAccelerationType.videotoolbox
|
||||||
|
&& RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64)))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var decoder = hardwareAccelerationType switch
|
var decoder = hardwareAccelerationType switch
|
||||||
{
|
{
|
||||||
HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth),
|
HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth),
|
||||||
@@ -7103,9 +7160,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
|
private string GetFfmpegAnalyzeDurationArg(EncodingJobInfo state)
|
||||||
{
|
{
|
||||||
var inputModifier = string.Empty;
|
|
||||||
var analyzeDurationArgument = string.Empty;
|
var analyzeDurationArgument = string.Empty;
|
||||||
|
|
||||||
// Apply -analyzeduration as per the environment variable,
|
// Apply -analyzeduration as per the environment variable,
|
||||||
@@ -7121,6 +7177,26 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
analyzeDurationArgument = "-analyzeduration " + ffmpegAnalyzeDuration;
|
analyzeDurationArgument = "-analyzeduration " + ffmpegAnalyzeDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return analyzeDurationArgument;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetFfmpegProbesizeArg()
|
||||||
|
{
|
||||||
|
var ffmpegProbeSize = _config.GetFFmpegProbeSize();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(ffmpegProbeSize))
|
||||||
|
{
|
||||||
|
return $"-probesize {ffmpegProbeSize}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
|
||||||
|
{
|
||||||
|
var inputModifier = string.Empty;
|
||||||
|
var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(analyzeDurationArgument))
|
if (!string.IsNullOrEmpty(analyzeDurationArgument))
|
||||||
{
|
{
|
||||||
inputModifier += " " + analyzeDurationArgument;
|
inputModifier += " " + analyzeDurationArgument;
|
||||||
@@ -7129,11 +7205,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
inputModifier = inputModifier.Trim();
|
inputModifier = inputModifier.Trim();
|
||||||
|
|
||||||
// Apply -probesize if configured
|
// Apply -probesize if configured
|
||||||
var ffmpegProbeSize = _config.GetFFmpegProbeSize();
|
var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(ffmpegProbeSize))
|
if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
|
||||||
{
|
{
|
||||||
inputModifier += $" -probesize {ffmpegProbeSize}";
|
inputModifier += " " + ffmpegProbeSizeArgument;
|
||||||
}
|
}
|
||||||
|
|
||||||
var userAgentParam = GetUserAgentParam(state);
|
var userAgentParam = GetUserAgentParam(state);
|
||||||
@@ -7173,8 +7249,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion);
|
inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int readrate = 0;
|
||||||
if (state.ReadInputAtNativeFramerate && state.InputProtocol != MediaProtocol.Rtsp)
|
if (state.ReadInputAtNativeFramerate && state.InputProtocol != MediaProtocol.Rtsp)
|
||||||
{
|
{
|
||||||
|
readrate = 1;
|
||||||
inputModifier += " -re";
|
inputModifier += " -re";
|
||||||
}
|
}
|
||||||
else if (encodingOptions.EnableSegmentDeletion
|
else if (encodingOptions.EnableSegmentDeletion
|
||||||
@@ -7185,7 +7263,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
{
|
{
|
||||||
// Set an input read rate limit 10x for using SegmentDeletion with stream-copy
|
// Set an input read rate limit 10x for using SegmentDeletion with stream-copy
|
||||||
// to prevent ffmpeg from exiting prematurely (due to fast drive)
|
// to prevent ffmpeg from exiting prematurely (due to fast drive)
|
||||||
inputModifier += " -readrate 10";
|
readrate = 10;
|
||||||
|
inputModifier += $" -readrate {readrate}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a larger catchup value to revert to the old behavior,
|
||||||
|
// otherwise, remuxing might stall due to this new option
|
||||||
|
if (readrate > 0 && _mediaEncoder.EncoderVersion >= _minFFmpegReadrateCatchupOption)
|
||||||
|
{
|
||||||
|
inputModifier += $" -readrate_catchup {readrate * 100}";
|
||||||
}
|
}
|
||||||
|
|
||||||
var flags = new List<string>();
|
var flags = new List<string>();
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ public interface IItemRepository
|
|||||||
|
|
||||||
void SaveImages(BaseItem item);
|
void SaveImages(BaseItem item);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reattaches the user data to the item.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
|
||||||
|
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the item.
|
/// Retrieves the item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -350,5 +350,12 @@ namespace MediaBrowser.Controller.Session
|
|||||||
/// <param name="sessionIdOrPlaySessionId">The session id or playsession id.</param>
|
/// <param name="sessionIdOrPlaySessionId">The session id or playsession id.</param>
|
||||||
/// <returns>Task.</returns>
|
/// <returns>Task.</returns>
|
||||||
Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId);
|
Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the dto for session info.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionInfo">The session info.</param>
|
||||||
|
/// <returns><see cref="SessionInfoDto"/> of the session.</returns>
|
||||||
|
SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Linq;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AsyncKeyedLock;
|
using AsyncKeyedLock;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
@@ -98,9 +99,21 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||||||
MediaSourceInfo mediaSource,
|
MediaSourceInfo mediaSource,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
|
var hasUnsafeFileName = mediaSource.MediaAttachments.Any(a => !IsSafeBulkAttachmentFileName(a.FileName));
|
||||||
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
|
var isMatroskaSubtitles = inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase);
|
||||||
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
if (hasUnsafeFileName && isMatroskaSubtitles)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Refusing attachment extraction for .mks input {InputFile}: an attachment FileName tag is not a safe leaf name.",
|
||||||
|
inputFile);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"Refusing attachment extraction for .mks input {0}: unsafe attachment FileName tag.",
|
||||||
|
inputFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnsafeFileName)
|
||||||
{
|
{
|
||||||
foreach (var attachment in mediaSource.MediaAttachments)
|
foreach (var attachment in mediaSource.MediaAttachments)
|
||||||
{
|
{
|
||||||
@@ -241,7 +254,9 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||||||
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
||||||
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
|
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture));
|
var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture);
|
||||||
|
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName)
|
||||||
|
?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
|
||||||
if (!File.Exists(attachmentPath))
|
if (!File.Exists(attachmentPath))
|
||||||
{
|
{
|
||||||
await ExtractAttachmentInternal(
|
await ExtractAttachmentInternal(
|
||||||
@@ -341,6 +356,27 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||||||
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides whether an attachment FILENAME tag is safe to feed into ffmpeg's
|
||||||
|
/// bulk <c>-dump_attachment:t ""</c> mode, which writes each attachment using
|
||||||
|
/// its filename tag verbatim relative to the working directory. Anything that
|
||||||
|
/// could escape the working directory - path separators, "..", or empty
|
||||||
|
/// leaves - is rerouted to the one-by-one path where the filename is
|
||||||
|
/// sanitised via <see cref="IPathManager.GetAttachmentPath"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsSafeBulkAttachmentFileName(string? fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(fileName))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathHelper.GetSafeLeafFileName collapses to the leaf component;
|
||||||
|
// the bulk path is only safe when the supplied name already _was_
|
||||||
|
// that leaf (no separators, no "."/"..").
|
||||||
|
return PathHelper.GetSafeLeafFileName(fileName) == fileName;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -693,7 +693,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||||||
[GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
[GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
||||||
private static partial Regex CodecRegex();
|
private static partial Regex CodecRegex();
|
||||||
|
|
||||||
[GeneratedRegex("^\\s\\S{3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
[GeneratedRegex("^\\s\\S{2,3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
||||||
private static partial Regex FilterRegex();
|
private static partial Regex FilterRegex();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,8 +299,11 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||||||
// Handle WebM
|
// Handle WebM
|
||||||
else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase))
|
else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
// Limit WebM to supported codecs
|
// Limit WebM to supported stream types and codecs.
|
||||||
if (mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
|
// FFprobe can report "matroska,webm" for Matroska-like containers, so only keep "webm" if all streams are WebM-compatible.
|
||||||
|
// Any stream that is not video nor audio is not supported in WebM and should disqualify the webm container probe result.
|
||||||
|
if (mediaStreams.Any(stream => stream.Type is not MediaStreamType.Video and not MediaStreamType.Audio)
|
||||||
|
|| mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
|
||||||
|| (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
|
|| (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
|
||||||
{
|
{
|
||||||
splitFormat[i] = string.Empty;
|
splitFormat[i] = string.Empty;
|
||||||
@@ -853,7 +856,12 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||||||
}
|
}
|
||||||
|
|
||||||
// http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe
|
// http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe
|
||||||
if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
|
if (string.IsNullOrEmpty(streamInfo.SampleAspectRatio)
|
||||||
|
&& string.IsNullOrEmpty(streamInfo.DisplayAspectRatio))
|
||||||
|
{
|
||||||
|
stream.IsAnamorphic = false;
|
||||||
|
}
|
||||||
|
else if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
stream.IsAnamorphic = false;
|
stream.IsAnamorphic = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ using MediaBrowser.Controller.Entities;
|
|||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.MediaEncoding;
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
|
using MediaBrowser.MediaEncoding.Encoder;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
@@ -321,7 +322,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
{
|
{
|
||||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
if (!File.Exists(outputPath))
|
if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||||
{
|
{
|
||||||
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
|
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
@@ -372,7 +373,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
FileName = _mediaEncoder.EncoderPath,
|
FileName = _mediaEncoder.EncoderPath,
|
||||||
Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
|
Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, EncodingUtils.NormalizePath(inputPath), EncodingUtils.NormalizePath(outputPath)),
|
||||||
WindowStyle = ProcessWindowStyle.Hidden,
|
WindowStyle = ProcessWindowStyle.Hidden,
|
||||||
ErrorDialog = false
|
ErrorDialog = false
|
||||||
},
|
},
|
||||||
@@ -423,9 +424,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (!File.Exists(outputPath))
|
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||||
{
|
{
|
||||||
failed = true;
|
failed = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
|
||||||
|
_fileSystem.DeleteFile(outputPath);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failed)
|
if (failed)
|
||||||
@@ -499,7 +513,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
|
|
||||||
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
|
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (File.Exists(outputPath))
|
if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
|
||||||
{
|
{
|
||||||
releaser.Dispose();
|
releaser.Dispose();
|
||||||
continue;
|
continue;
|
||||||
@@ -556,7 +570,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
var outputPaths = new List<string>();
|
var outputPaths = new List<string>();
|
||||||
var args = string.Format(
|
var args = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"-i {0} -copyts",
|
"-i {0}",
|
||||||
inputPath);
|
inputPath);
|
||||||
|
|
||||||
foreach (var subtitleStream in subtitleStreams)
|
foreach (var subtitleStream in subtitleStreams)
|
||||||
@@ -581,7 +595,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
outputPaths.Add(outputPath);
|
outputPaths.Add(outputPath);
|
||||||
args += string.Format(
|
args += string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
" -map 0:{0} -an -vn -c:s {1} \"{2}\"",
|
" -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
|
||||||
streamIndex,
|
streamIndex,
|
||||||
outputCodec,
|
outputCodec,
|
||||||
outputPath);
|
outputPath);
|
||||||
@@ -600,7 +614,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
var outputPaths = new List<string>();
|
var outputPaths = new List<string>();
|
||||||
var args = string.Format(
|
var args = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"-i {0} -copyts",
|
"-i {0}",
|
||||||
inputPath);
|
inputPath);
|
||||||
|
|
||||||
foreach (var subtitleStream in subtitleStreams)
|
foreach (var subtitleStream in subtitleStreams)
|
||||||
@@ -626,7 +640,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
outputPaths.Add(outputPath);
|
outputPaths.Add(outputPath);
|
||||||
args += string.Format(
|
args += string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
" -map 0:{0} -an -vn -c:s {1} \"{2}\"",
|
" -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
|
||||||
streamIndex,
|
streamIndex,
|
||||||
outputCodec,
|
outputCodec,
|
||||||
outputPath);
|
outputPath);
|
||||||
@@ -713,10 +727,24 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
{
|
{
|
||||||
foreach (var outputPath in outputPaths)
|
foreach (var outputPath in outputPaths)
|
||||||
{
|
{
|
||||||
if (!File.Exists(outputPath))
|
if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||||
{
|
{
|
||||||
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||||
failed = true;
|
failed = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
|
||||||
|
_fileSystem.DeleteFile(outputPath);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,7 +783,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
{
|
{
|
||||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
if (!File.Exists(outputPath))
|
if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||||
{
|
{
|
||||||
var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
||||||
|
|
||||||
@@ -857,9 +885,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (!File.Exists(outputPath))
|
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||||
{
|
{
|
||||||
failed = true;
|
failed = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
|
||||||
|
_fileSystem.DeleteFile(outputPath);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failed)
|
if (failed)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Model</PackageId>
|
<PackageId>Jellyfin.Model</PackageId>
|
||||||
<VersionPrefix>10.11.4</VersionPrefix>
|
<VersionPrefix>10.11.10</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
|
|||||||
{
|
{
|
||||||
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
|
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
|
||||||
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
|
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
|
||||||
if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
|
if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
|
||||||
{
|
{
|
||||||
savePaths.Add(mediaFolderPath);
|
savePaths.Add(mediaFolderPath);
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
|
|||||||
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
|
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
|
||||||
|
|
||||||
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
|
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
|
||||||
if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
|
if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
|
||||||
{
|
{
|
||||||
savePaths.Add(internalPath);
|
savePaths.Add(internalPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ namespace MediaBrowser.Providers.Manager
|
|||||||
|
|
||||||
if (isFirstRefresh)
|
if (isFirstRefresh)
|
||||||
{
|
{
|
||||||
await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, false, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next run metadata providers
|
// Next run metadata providers
|
||||||
@@ -247,7 +247,7 @@ namespace MediaBrowser.Providers.Manager
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false);
|
await SaveItemAsync(metadataResult, updateType, isFirstRefresh, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updateType;
|
return updateType;
|
||||||
@@ -275,9 +275,14 @@ namespace MediaBrowser.Providers.Manager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken)
|
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
|
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (reattachUserData)
|
||||||
|
{
|
||||||
|
await result.Item.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.Item.SupportsPeople && result.People is not null)
|
if (result.Item.SupportsPeople && result.People is not null)
|
||||||
{
|
{
|
||||||
var baseItem = result.Item;
|
var baseItem = result.Item;
|
||||||
|
|||||||
@@ -262,9 +262,28 @@ namespace MediaBrowser.Providers.MediaInfo
|
|||||||
|
|
||||||
private void FetchShortcutInfo(BaseItem item)
|
private void FetchShortcutInfo(BaseItem item)
|
||||||
{
|
{
|
||||||
item.ShortcutPath = File.ReadAllLines(item.Path)
|
var shortcutPath = File.ReadAllLines(item.Path)
|
||||||
.Select(NormalizeStrmLine)
|
.Select(NormalizeStrmLine)
|
||||||
.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#'));
|
.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#'));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(shortcutPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow remote URLs in .strm files to prevent local file access
|
||||||
|
if (Uri.TryCreate(shortcutPath, UriKind.Absolute, out var uri)
|
||||||
|
&& (string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "rtsp", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "rtp", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
item.ShortcutPath = shortcutPath;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Ignoring invalid or non-remote .strm path in {File}: {Path}", item.Path, shortcutPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
|
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replaceData || targetItem.Shares.Count == 0)
|
if (replaceData || targetItem.Shares.Count == 0)
|
||||||
|
|||||||
@@ -303,9 +303,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
|||||||
CrewMember = crewMember,
|
CrewMember = crewMember,
|
||||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||||
})
|
})
|
||||||
.Where(entry =>
|
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
|
||||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (config.HideMissingCrewMembers)
|
if (config.HideMissingCrewMembers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -275,9 +275,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
|||||||
CrewMember = crewMember,
|
CrewMember = crewMember,
|
||||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||||
})
|
})
|
||||||
.Where(entry =>
|
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
|
||||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (config.HideMissingCrewMembers)
|
if (config.HideMissingCrewMembers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
|||||||
CrewMember = crewMember,
|
CrewMember = crewMember,
|
||||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||||
})
|
})
|
||||||
.Where(entry =>
|
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
|
||||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (config.HideMissingCrewMembers)
|
if (config.HideMissingCrewMembers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -367,9 +367,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
|||||||
CrewMember = crewMember,
|
CrewMember = crewMember,
|
||||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||||
})
|
})
|
||||||
.Where(entry =>
|
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
|
||||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (config.HideMissingCrewMembers)
|
if (config.HideMissingCrewMembers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -518,7 +518,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _tmDbClient.GetImageUrl(size, path, true).ToString();
|
// Use "original" as default size if size is null or empty to prevent malformed URLs
|
||||||
|
var imageSize = string.IsNullOrEmpty(size) ? "original" : size;
|
||||||
|
|
||||||
|
return _tmDbClient.GetImageUrl(imageSize, path, true).ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -69,19 +69,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
|||||||
/// <returns>The Jellyfin person type.</returns>
|
/// <returns>The Jellyfin person type.</returns>
|
||||||
public static PersonKind MapCrewToPersonType(Crew crew)
|
public static PersonKind MapCrewToPersonType(Crew crew)
|
||||||
{
|
{
|
||||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
if (crew.Department.Equals("directing", StringComparison.OrdinalIgnoreCase)
|
||||||
&& crew.Job.Contains("director", StringComparison.OrdinalIgnoreCase))
|
&& crew.Job.Equals("director", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return PersonKind.Director;
|
return PersonKind.Director;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
||||||
&& crew.Job.Contains("producer", StringComparison.OrdinalIgnoreCase))
|
&& crew.Job.Equals("producer", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return PersonKind.Producer;
|
return PersonKind.Producer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase))
|
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase) || crew.Job.Equals("screenplay", StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
return PersonKind.Writer;
|
return PersonKind.Writer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Emby.Naming.Common;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -32,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
private readonly ILibraryMonitor _monitor;
|
private readonly ILibraryMonitor _monitor;
|
||||||
private readonly IMediaSourceManager _mediaSourceManager;
|
private readonly IMediaSourceManager _mediaSourceManager;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
|
private readonly HashSet<string> _allowedSubtitleFormats;
|
||||||
|
|
||||||
private readonly ISubtitleProvider[] _subtitleProviders;
|
private readonly ISubtitleProvider[] _subtitleProviders;
|
||||||
|
|
||||||
@@ -41,7 +43,8 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
ILibraryMonitor monitor,
|
ILibraryMonitor monitor,
|
||||||
IMediaSourceManager mediaSourceManager,
|
IMediaSourceManager mediaSourceManager,
|
||||||
ILocalizationManager localizationManager,
|
ILocalizationManager localizationManager,
|
||||||
IEnumerable<ISubtitleProvider> subtitleProviders)
|
IEnumerable<ISubtitleProvider> subtitleProviders,
|
||||||
|
NamingOptions namingOptions)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
@@ -51,6 +54,9 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
_subtitleProviders = subtitleProviders
|
_subtitleProviders = subtitleProviders
|
||||||
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
|
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
_allowedSubtitleFormats = new HashSet<string>(
|
||||||
|
namingOptions.SubtitleFileExtensions.Select(e => e.TrimStart('.')),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -171,6 +177,12 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task UploadSubtitle(Video video, SubtitleResponse response)
|
public Task UploadSubtitle(Video video, SubtitleResponse response)
|
||||||
{
|
{
|
||||||
|
var format = response.Format;
|
||||||
|
if (string.IsNullOrEmpty(format) || !_allowedSubtitleFormats.Contains(format))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Unsupported subtitle format: '{format}'");
|
||||||
|
}
|
||||||
|
|
||||||
var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
|
var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
|
||||||
return TrySaveSubtitle(video, libraryOptions, response);
|
return TrySaveSubtitle(video, libraryOptions, response);
|
||||||
}
|
}
|
||||||
@@ -193,7 +205,13 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
}
|
}
|
||||||
|
|
||||||
var savePaths = new List<string>();
|
var savePaths = new List<string>();
|
||||||
var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant();
|
var language = response.Language.ToLowerInvariant();
|
||||||
|
if (language.AsSpan().IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) >= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Language contains invalid characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + language;
|
||||||
|
|
||||||
if (response.IsForced)
|
if (response.IsForced)
|
||||||
{
|
{
|
||||||
@@ -221,15 +239,22 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
|
|
||||||
private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
|
private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
|
||||||
{
|
{
|
||||||
|
if (!_allowedSubtitleFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid subtitle format: {extension}");
|
||||||
|
}
|
||||||
|
|
||||||
List<Exception>? exs = null;
|
List<Exception>? exs = null;
|
||||||
|
|
||||||
foreach (var savePath in savePaths)
|
foreach (var savePath in savePaths)
|
||||||
{
|
{
|
||||||
var path = savePath + "." + extension;
|
var path = Path.GetFullPath(savePath + "." + extension);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (path.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal)
|
var containingFolder = video.ContainingFolderPath + Path.DirectorySeparatorChar;
|
||||||
|| path.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal))
|
var metadataFolder = video.GetInternalMetadataPath() + Path.DirectorySeparatorChar;
|
||||||
|
if (path.StartsWith(containingFolder, StringComparison.Ordinal)
|
||||||
|
|| path.StartsWith(metadataFolder, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var fileExists = File.Exists(path);
|
var fileExists = File.Exists(path);
|
||||||
var counter = 0;
|
var counter = 0;
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
|
|||||||
writer.WriteElementString("aspectratio", hasAspectRatio.AspectRatio);
|
writer.WriteElementString("aspectratio", hasAspectRatio.AspectRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbCollection))
|
if (item.TryGetProviderId(MetadataProvider.TmdbCollection, out var tmdbCollection))
|
||||||
{
|
{
|
||||||
writer.WriteElementString("collectionnumber", tmdbCollection);
|
writer.WriteElementString("collectionnumber", tmdbCollection);
|
||||||
writtenProviderIds.Add(MetadataProvider.TmdbCollection.ToString());
|
writtenProviderIds.Add(MetadataProvider.TmdbCollection.ToString());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
[assembly: AssemblyVersion("10.11.4")]
|
[assembly: AssemblyVersion("10.11.10")]
|
||||||
[assembly: AssemblyFileVersion("10.11.4")]
|
[assembly: AssemblyFileVersion("10.11.10")]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities
|
|||||||
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
|
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
|
||||||
|
|
||||||
Username = username;
|
Username = username;
|
||||||
|
NormalizedUsername = username.ToUpperInvariant();
|
||||||
AuthenticationProviderId = authenticationProviderId;
|
AuthenticationProviderId = authenticationProviderId;
|
||||||
PasswordResetProviderId = passwordResetProviderId;
|
PasswordResetProviderId = passwordResetProviderId;
|
||||||
|
|
||||||
@@ -73,6 +74,16 @@ namespace Jellyfin.Database.Implementations.Entities
|
|||||||
[StringLength(255)]
|
[StringLength(255)]
|
||||||
public string Username { get; set; }
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the user's normalized name.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required, Max length = 255.
|
||||||
|
/// </remarks>
|
||||||
|
[MaxLength(255)]
|
||||||
|
[StringLength(255)]
|
||||||
|
public string NormalizedUsername { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the user's password, or <c>null</c> if none is set.
|
/// Gets or sets the user's password, or <c>null</c> if none is set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -268,6 +268,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
|
|||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
// a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
logger.LogError(e, "Error trying to save changes.");
|
logger.LogError(e, "Error trying to save changes.");
|
||||||
@@ -289,6 +294,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
// a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
logger.LogError(e, "Error trying to save changes.");
|
logger.LogError(e, "Error trying to save changes.");
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
|
|||||||
builder
|
builder
|
||||||
.HasIndex(entity => entity.Username)
|
.HasIndex(entity => entity.Username)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
builder
|
||||||
|
.HasIndex(entity => entity.NormalizedUsername)
|
||||||
|
.IsUnique();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNormalizedUsername : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// this is the first part of the migration. Add the column.
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NormalizedUsername",
|
||||||
|
table: "Users",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 255,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NormalizedUsername",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUniqueNormalizedUsernameIndex : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Users_NormalizedUsername",
|
||||||
|
table: "Users",
|
||||||
|
column: "NormalizedUsername",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Users_NormalizedUsername",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
|
||||||
|
|
||||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
|
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
|
||||||
{
|
{
|
||||||
@@ -1303,6 +1303,11 @@ namespace Jellyfin.Server.Implementations.Migrations
|
|||||||
b.Property<bool>("MustUpdatePassword")
|
b.Property<bool>("MustUpdatePassword")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Password")
|
b.Property<string>("Password")
|
||||||
.HasMaxLength(65535)
|
.HasMaxLength(65535)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -1345,6 +1350,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUsername")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("Username")
|
b.HasIndex("Username")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|||||||
@@ -24,61 +24,29 @@ public class SkiaEncoder : IImageEncoder
|
|||||||
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
|
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
|
||||||
private readonly ILogger<SkiaEncoder> _logger;
|
private readonly ILogger<SkiaEncoder> _logger;
|
||||||
private readonly IApplicationPaths _appPaths;
|
private readonly IApplicationPaths _appPaths;
|
||||||
private static readonly SKImageFilter _imageFilter;
|
private static readonly SKTypeface?[] _typefaces = InitializeTypefaces();
|
||||||
private static readonly SKTypeface[] _typefaces;
|
private static readonly SKImageFilter _imageFilter = SKImageFilter.CreateMatrixConvolution(
|
||||||
|
new SKSizeI(3, 3),
|
||||||
|
[
|
||||||
|
0, -.1f, 0,
|
||||||
|
-.1f, 1.4f, -.1f,
|
||||||
|
0, -.1f, 0
|
||||||
|
],
|
||||||
|
1f,
|
||||||
|
0f,
|
||||||
|
new SKPointI(1, 1),
|
||||||
|
SKShaderTileMode.Clamp,
|
||||||
|
true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The default sampling options, equivalent to old high quality filter settings when upscaling.
|
/// The default sampling options, equivalent to old high quality filter settings when upscaling.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly SKSamplingOptions UpscaleSamplingOptions;
|
public static readonly SKSamplingOptions UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upscaling.
|
/// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upscaling.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly SKSamplingOptions DefaultSamplingOptions;
|
public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
|
||||||
|
|
||||||
#pragma warning disable CA1810
|
|
||||||
static SkiaEncoder()
|
|
||||||
#pragma warning restore CA1810
|
|
||||||
{
|
|
||||||
var kernel = new[]
|
|
||||||
{
|
|
||||||
0, -.1f, 0,
|
|
||||||
-.1f, 1.4f, -.1f,
|
|
||||||
0, -.1f, 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
var kernelSize = new SKSizeI(3, 3);
|
|
||||||
var kernelOffset = new SKPointI(1, 1);
|
|
||||||
_imageFilter = SKImageFilter.CreateMatrixConvolution(
|
|
||||||
kernelSize,
|
|
||||||
kernel,
|
|
||||||
1f,
|
|
||||||
0f,
|
|
||||||
kernelOffset,
|
|
||||||
SKShaderTileMode.Clamp,
|
|
||||||
true);
|
|
||||||
|
|
||||||
// Initialize the list of typefaces
|
|
||||||
// We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code point
|
|
||||||
// But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻♀️ is a single emoji but 5 code points (U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F)
|
|
||||||
_typefaces =
|
|
||||||
[
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '鸡'), // CJK Simplified Chinese
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '雞'), // CJK Traditional Chinese
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ノ'), // CJK Japanese
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '각'), // CJK Korean
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 128169), // Emojis, 128169 is the 💩emoji
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ז'), // Hebrew
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ي'), // Arabic
|
|
||||||
SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) // Default font
|
|
||||||
];
|
|
||||||
|
|
||||||
// use cubic for upscaling
|
|
||||||
UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell);
|
|
||||||
// use bilinear for everything else
|
|
||||||
DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
|
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
|
||||||
@@ -132,7 +100,7 @@ public class SkiaEncoder : IImageEncoder
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the default typeface to use.
|
/// Gets the default typeface to use.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static SKTypeface DefaultTypeFace => _typefaces.Last();
|
public static SKTypeface? DefaultTypeFace => _typefaces.Last();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Check if the native lib is available.
|
/// Check if the native lib is available.
|
||||||
@@ -152,6 +120,40 @@ public class SkiaEncoder : IImageEncoder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize the list of typefaces
|
||||||
|
/// We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code point
|
||||||
|
/// But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻♀️ is a single emoji but 5 code points (U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The list of typefaces.</returns>
|
||||||
|
private static SKTypeface?[] InitializeTypefaces()
|
||||||
|
{
|
||||||
|
int[] chars = [
|
||||||
|
'鸡', // CJK Simplified Chinese
|
||||||
|
'雞', // CJK Traditional Chinese
|
||||||
|
'ノ', // CJK Japanese
|
||||||
|
'각', // CJK Korean
|
||||||
|
128169, // Emojis, 128169 is the Pile of Poo (💩) emoji
|
||||||
|
'ז', // Hebrew
|
||||||
|
'ي' // Arabic
|
||||||
|
];
|
||||||
|
var fonts = new List<SKTypeface>(chars.Length + 1);
|
||||||
|
foreach (var ch in chars)
|
||||||
|
{
|
||||||
|
var font = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, ch);
|
||||||
|
if (font is not null)
|
||||||
|
{
|
||||||
|
fonts.Add(font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default font
|
||||||
|
fonts.Add(SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright)
|
||||||
|
?? SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'a'));
|
||||||
|
|
||||||
|
return fonts.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
|
/// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -809,7 +811,7 @@ public class SkiaEncoder : IImageEncoder
|
|||||||
{
|
{
|
||||||
foreach (var typeface in _typefaces)
|
foreach (var typeface in _typefaces)
|
||||||
{
|
{
|
||||||
if (typeface.ContainsGlyphs(c))
|
if (typeface is not null && typeface.ContainsGlyphs(c))
|
||||||
{
|
{
|
||||||
return typeface;
|
return typeface;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Extensions</PackageId>
|
<PackageId>Jellyfin.Extensions</PackageId>
|
||||||
<VersionPrefix>10.11.4</VersionPrefix>
|
<VersionPrefix>10.11.10</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
77
src/Jellyfin.Extensions/PathHelper.cs
Normal file
77
src/Jellyfin.Extensions/PathHelper.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Jellyfin.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helpers for safely composing filesystem paths from untrusted input.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="Path.Combine(string, string)"/> has two issues that matter in
|
||||||
|
/// any code that joins a trusted directory with an externally-supplied name:
|
||||||
|
/// it neither normalises <c>..</c> nor rejects a rooted second argument
|
||||||
|
/// (a rooted second arg silently discards the first). Use the helpers below
|
||||||
|
/// any time the name comes from media metadata, request input, archive
|
||||||
|
/// entries, or any other channel that can be influenced by a third party.
|
||||||
|
/// </remarks>
|
||||||
|
public static class PathHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reduces a possibly-untrusted file name to a safe leaf-only name with no
|
||||||
|
/// directory components.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileName">The candidate file name.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The leaf component of <paramref name="fileName"/>, or <c>null</c> if
|
||||||
|
/// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>).
|
||||||
|
/// </returns>
|
||||||
|
public static string? GetSafeLeafFileName(string? fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(fileName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaf = Path.GetFileName(fileName);
|
||||||
|
if (string.IsNullOrEmpty(leaf) || leaf == "." || leaf == "..")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns whether <paramref name="candidate"/> resolves to a path that
|
||||||
|
/// equals or is contained inside <paramref name="root"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="root">The directory the candidate must remain inside.</param>
|
||||||
|
/// <param name="candidate">The candidate absolute or relative path.</param>
|
||||||
|
/// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/>
|
||||||
|
/// so <c>..</c> segments are collapsed before the comparison. The root is
|
||||||
|
/// compared with a trailing directory separator to prevent prefix
|
||||||
|
/// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of
|
||||||
|
/// <c>/var/dataset</c>).
|
||||||
|
/// </remarks>
|
||||||
|
public static bool IsContainedIn(string root, string candidate)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(root);
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(candidate);
|
||||||
|
|
||||||
|
var fullRoot = Path.GetFullPath(root);
|
||||||
|
var fullCandidate = Path.GetFullPath(candidate);
|
||||||
|
|
||||||
|
if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar)
|
||||||
|
? fullRoot
|
||||||
|
: fullRoot + Path.DirectorySeparatorChar;
|
||||||
|
|
||||||
|
return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,6 +156,13 @@ namespace Jellyfin.LiveTv.IO
|
|||||||
if (mediaSource.ReadAtNativeFramerate)
|
if (mediaSource.ReadAtNativeFramerate)
|
||||||
{
|
{
|
||||||
inputModifier += " -re";
|
inputModifier += " -re";
|
||||||
|
|
||||||
|
// Set a larger catchup value to revert to the old behavior,
|
||||||
|
// otherwise, remuxing might stall due to this new option
|
||||||
|
if (_mediaEncoder.EncoderVersion >= new Version(8, 0))
|
||||||
|
{
|
||||||
|
inputModifier += " -readrate_catchup 100";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaSource.RequiresLooping)
|
if (mediaSource.RequiresLooping)
|
||||||
|
|||||||
@@ -1204,7 +1204,7 @@ namespace Jellyfin.LiveTv
|
|||||||
{
|
{
|
||||||
Services = services,
|
Services = services,
|
||||||
IsEnabled = services.Length > 0,
|
IsEnabled = services.Length > 0,
|
||||||
EnabledUsers = _userManager.Users
|
EnabledUsers = _userManager.GetUsers()
|
||||||
.Where(IsLiveTvEnabled)
|
.Where(IsLiveTvEnabled)
|
||||||
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
|
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
|
||||||
.ToArray()
|
.ToArray()
|
||||||
@@ -1220,7 +1220,7 @@ namespace Jellyfin.LiveTv
|
|||||||
|
|
||||||
public IEnumerable<User> GetEnabledUsers()
|
public IEnumerable<User> GetEnabledUsers()
|
||||||
{
|
{
|
||||||
return _userManager.Users
|
return _userManager.GetUsers()
|
||||||
.Where(IsLiveTvEnabled);
|
.Where(IsLiveTvEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ namespace Jellyfin.LiveTv.Recordings
|
|||||||
|
|
||||||
private async Task SendMessage(SessionMessageType name, TimerEventInfo info)
|
private async Task SendMessage(SessionMessageType name, TimerEventInfo info)
|
||||||
{
|
{
|
||||||
var users = _userManager.Users
|
var users = _userManager.GetUsers()
|
||||||
.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess))
|
.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess))
|
||||||
.Select(i => i.Id)
|
.Select(i => i.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ namespace Jellyfin.LiveTv.TunerHosts
|
|||||||
}
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#'))
|
else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#'))
|
||||||
{
|
{
|
||||||
|
if (!IsValidChannelUrl(trimmedLine))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Skipping M3U channel entry with non-HTTP path: {Path}", trimmedLine);
|
||||||
|
extInf = string.Empty;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var channel = GetChannelInfo(extInf, tunerHostId, trimmedLine);
|
var channel = GetChannelInfo(extInf, tunerHostId, trimmedLine);
|
||||||
channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
@@ -247,6 +254,16 @@ namespace Jellyfin.LiveTv.TunerHosts
|
|||||||
return numberString;
|
return numberString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidChannelUrl(string url)
|
||||||
|
{
|
||||||
|
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||||
|
&& (string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "rtsp", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "rtp", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "udp", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsValidChannelNumber(string numberString)
|
private static bool IsValidChannelNumber(string numberString)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(numberString)
|
if (string.IsNullOrWhiteSpace(numberString)
|
||||||
|
|||||||
44
tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
Normal file
44
tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.ClientEvent;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Controller.Tests
|
||||||
|
{
|
||||||
|
public class ClientEventLoggerTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("../../../../etc/passwd", "1.0")]
|
||||||
|
[InlineData("..\\..\\windows\\system32", "1.0")]
|
||||||
|
[InlineData("normal-client", "../../../etc/passwd")]
|
||||||
|
[InlineData("/absolute/path", "1.0")]
|
||||||
|
public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion)
|
||||||
|
{
|
||||||
|
var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName());
|
||||||
|
Directory.CreateDirectory(logDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var paths = new Mock<IServerApplicationPaths>();
|
||||||
|
paths.Setup(p => p.LogDirectoryPath).Returns(logDir);
|
||||||
|
|
||||||
|
var logger = new ClientEventLogger(paths.Object);
|
||||||
|
using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
|
||||||
|
|
||||||
|
var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents);
|
||||||
|
|
||||||
|
var resolved = Path.GetFullPath(Path.Combine(logDir, fileName));
|
||||||
|
var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar;
|
||||||
|
Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal);
|
||||||
|
Assert.True(File.Exists(resolved));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(logDir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,6 +195,18 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
|
|||||||
Assert.False(res.MediaStreams[0].IsAVC);
|
Assert.False(res.MediaStreams[0].IsAVC);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetMediaInfo_WebM_Like_Mkv()
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes("Test Data/Probing/video_web_like_mkv_with_subtitle.json");
|
||||||
|
var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
|
||||||
|
|
||||||
|
MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File);
|
||||||
|
|
||||||
|
Assert.Equal("mkv", res.Container);
|
||||||
|
Assert.Equal(3, res.MediaStreams.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success()
|
public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"codec_name": "vp8",
|
||||||
|
"codec_long_name": "On2 VP8",
|
||||||
|
"profile": "1",
|
||||||
|
"codec_type": "video",
|
||||||
|
"codec_tag_string": "[0][0][0][0]",
|
||||||
|
"codec_tag": "0x0000",
|
||||||
|
"width": 540,
|
||||||
|
"height": 360,
|
||||||
|
"coded_width": 540,
|
||||||
|
"coded_height": 360,
|
||||||
|
"closed_captions": 0,
|
||||||
|
"film_grain": 0,
|
||||||
|
"has_b_frames": 0,
|
||||||
|
"sample_aspect_ratio": "1:1",
|
||||||
|
"display_aspect_ratio": "3:2",
|
||||||
|
"pix_fmt": "yuv420p",
|
||||||
|
"level": -99,
|
||||||
|
"field_order": "progressive",
|
||||||
|
"refs": 1,
|
||||||
|
"r_frame_rate": "2997/125",
|
||||||
|
"avg_frame_rate": "2997/125",
|
||||||
|
"time_base": "1/1000",
|
||||||
|
"start_pts": 0,
|
||||||
|
"start_time": "0.000000",
|
||||||
|
"disposition": {
|
||||||
|
"default": 1,
|
||||||
|
"dub": 0,
|
||||||
|
"original": 0,
|
||||||
|
"comment": 0,
|
||||||
|
"lyrics": 0,
|
||||||
|
"karaoke": 0,
|
||||||
|
"forced": 0,
|
||||||
|
"hearing_impaired": 0,
|
||||||
|
"visual_impaired": 0,
|
||||||
|
"clean_effects": 0,
|
||||||
|
"attached_pic": 0,
|
||||||
|
"timed_thumbnails": 0,
|
||||||
|
"captions": 0,
|
||||||
|
"descriptions": 0,
|
||||||
|
"metadata": 0,
|
||||||
|
"dependent": 0,
|
||||||
|
"still_image": 0
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"language": "eng"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"codec_name": "vorbis",
|
||||||
|
"codec_long_name": "Vorbis",
|
||||||
|
"codec_type": "audio",
|
||||||
|
"codec_tag_string": "[0][0][0][0]",
|
||||||
|
"codec_tag": "0x0000",
|
||||||
|
"sample_fmt": "fltp",
|
||||||
|
"sample_rate": "44100",
|
||||||
|
"channels": 2,
|
||||||
|
"channel_layout": "stereo",
|
||||||
|
"bits_per_sample": 0,
|
||||||
|
"r_frame_rate": "0/0",
|
||||||
|
"avg_frame_rate": "0/0",
|
||||||
|
"time_base": "1/1000",
|
||||||
|
"start_pts": 0,
|
||||||
|
"start_time": "0.000000",
|
||||||
|
"duration": "117.707000",
|
||||||
|
"bit_rate": "127998",
|
||||||
|
"disposition": {
|
||||||
|
"default": 1,
|
||||||
|
"dub": 0,
|
||||||
|
"original": 0,
|
||||||
|
"comment": 0,
|
||||||
|
"lyrics": 0,
|
||||||
|
"karaoke": 0,
|
||||||
|
"forced": 0,
|
||||||
|
"hearing_impaired": 0,
|
||||||
|
"visual_impaired": 0,
|
||||||
|
"clean_effects": 0,
|
||||||
|
"attached_pic": 0,
|
||||||
|
"timed_thumbnails": 0,
|
||||||
|
"captions": 0,
|
||||||
|
"descriptions": 0,
|
||||||
|
"metadata": 0,
|
||||||
|
"dependent": 0,
|
||||||
|
"still_image": 0
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"language": "eng"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"codec_name": "subrip",
|
||||||
|
"codec_long_name": "SubRip subtitle",
|
||||||
|
"codec_type": "subtitle",
|
||||||
|
"codec_tag_string": "[0][0][0][0]",
|
||||||
|
"codec_tag": "0x0000",
|
||||||
|
"disposition": {
|
||||||
|
"default": 0,
|
||||||
|
"dub": 0,
|
||||||
|
"original": 0,
|
||||||
|
"comment": 0,
|
||||||
|
"lyrics": 0,
|
||||||
|
"karaoke": 0,
|
||||||
|
"forced": 0,
|
||||||
|
"hearing_impaired": 0,
|
||||||
|
"visual_impaired": 0,
|
||||||
|
"clean_effects": 0,
|
||||||
|
"attached_pic": 0,
|
||||||
|
"timed_thumbnails": 0,
|
||||||
|
"captions": 0,
|
||||||
|
"descriptions": 0,
|
||||||
|
"metadata": 0,
|
||||||
|
"dependent": 0,
|
||||||
|
"still_image": 0
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"language": "eng"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"format": {
|
||||||
|
"filename": "sample.mkv",
|
||||||
|
"nb_streams": 3,
|
||||||
|
"nb_programs": 0,
|
||||||
|
"format_name": "matroska,webm",
|
||||||
|
"format_long_name": "Matroska / WebM",
|
||||||
|
"start_time": "0.000000",
|
||||||
|
"duration": "117.700914",
|
||||||
|
"size": "8566268",
|
||||||
|
"bit_rate": "582239",
|
||||||
|
"probe_score": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,25 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
|
|||||||
Assert.Null(localizationManager.GetRatingScore(value));
|
Assert.Null(localizationManager.GetRatingScore(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("TV-MA", "DE", 17, 1)] // US-only rating, DE country code
|
||||||
|
[InlineData("PG-13", "FR", 13, 0)] // US-only rating, FR country code
|
||||||
|
[InlineData("R", "JP", 17, 0)] // US-only rating, JP country code
|
||||||
|
public async Task GetRatingScore_FallbackPrioritizesUS_Success(string rating, string countryCode, int expectedScore, int? expectedSubScore)
|
||||||
|
{
|
||||||
|
var localizationManager = Setup(new ServerConfiguration()
|
||||||
|
{
|
||||||
|
MetadataCountryCode = countryCode
|
||||||
|
});
|
||||||
|
await localizationManager.LoadAll();
|
||||||
|
|
||||||
|
var score = localizationManager.GetRatingScore(rating);
|
||||||
|
|
||||||
|
Assert.NotNull(score);
|
||||||
|
Assert.Equal(expectedScore, score.Score);
|
||||||
|
Assert.Equal(expectedSubScore, score.SubScore);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Default", "Default")]
|
[InlineData("Default", "Default")]
|
||||||
[InlineData("HeaderLiveTV", "Live TV")]
|
[InlineData("HeaderLiveTV", "Live TV")]
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Server.Implementations.Users;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Tests.Users
|
||||||
|
{
|
||||||
|
public class UserManagerLockHelperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
|
||||||
|
{
|
||||||
|
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||||
|
using var helper = new UserManager.LockHelper();
|
||||||
|
var key = Guid.NewGuid();
|
||||||
|
|
||||||
|
Assert.True(helper.ShouldLock());
|
||||||
|
|
||||||
|
var outerHandle = await helper.LockAsync(key);
|
||||||
|
Assert.False(helper.ShouldLock());
|
||||||
|
|
||||||
|
var innerHandle = await helper.LockAsync(key);
|
||||||
|
Assert.False(helper.ShouldLock());
|
||||||
|
|
||||||
|
innerHandle.Dispose();
|
||||||
|
Assert.False(helper.ShouldLock());
|
||||||
|
|
||||||
|
outerHandle.Dispose();
|
||||||
|
Assert.True(helper.ShouldLock());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
|
||||||
|
{
|
||||||
|
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||||
|
using var helper = new UserManager.LockHelper();
|
||||||
|
var key = Guid.NewGuid();
|
||||||
|
|
||||||
|
var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var secondEntered = false;
|
||||||
|
|
||||||
|
var firstTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var firstHandle = await helper.LockAsync(key);
|
||||||
|
firstAcquired.SetResult(true);
|
||||||
|
await releaseFirst.Task;
|
||||||
|
});
|
||||||
|
|
||||||
|
await firstAcquired.Task;
|
||||||
|
|
||||||
|
var secondTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var secondHandle = await helper.LockAsync(key);
|
||||||
|
secondEntered = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.Delay(100);
|
||||||
|
Assert.False(secondEntered);
|
||||||
|
|
||||||
|
releaseFirst.SetResult(true);
|
||||||
|
|
||||||
|
await Task.WhenAll(firstTask, secondTask);
|
||||||
|
Assert.True(secondEntered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
|
||||||
|
{
|
||||||
|
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||||
|
using var helper = new UserManager.LockHelper();
|
||||||
|
helper.Dispose();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
|
||||||
|
{
|
||||||
|
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||||
|
using var helper = new UserManager.LockHelper();
|
||||||
|
|
||||||
|
helper.Dispose();
|
||||||
|
var ex = Record.Exception(() => helper.Dispose());
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Locking;
|
||||||
|
using Jellyfin.Database.Providers.Sqlite;
|
||||||
|
using Jellyfin.Server.Implementations.Users;
|
||||||
|
using MediaBrowser.Common;
|
||||||
|
using MediaBrowser.Common.Net;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Drawing;
|
||||||
|
using MediaBrowser.Controller.Events;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Cryptography;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Tests.Users
|
||||||
|
{
|
||||||
|
public sealed class UserManagerNormalizedUsernameTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||||
|
private readonly UserManager _userManager;
|
||||||
|
|
||||||
|
public UserManagerNormalizedUsernameTests()
|
||||||
|
{
|
||||||
|
_connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
_connection.Open();
|
||||||
|
|
||||||
|
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||||
|
.UseSqlite(_connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
// Create the schema
|
||||||
|
using var ctx = CreateDbContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
|
||||||
|
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||||
|
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||||
|
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateDbContext);
|
||||||
|
|
||||||
|
var cryptoProvider = new Mock<ICryptoProvider>();
|
||||||
|
var configManager = new Mock<IServerConfigurationManager>();
|
||||||
|
var appPaths = new Mock<IServerApplicationPaths>();
|
||||||
|
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
|
||||||
|
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
|
||||||
|
|
||||||
|
var appHost = new Mock<IApplicationHost>();
|
||||||
|
|
||||||
|
var defaultAuthProvider = new DefaultAuthenticationProvider(
|
||||||
|
NullLogger<DefaultAuthenticationProvider>.Instance,
|
||||||
|
cryptoProvider.Object);
|
||||||
|
var invalidAuthProvider = new InvalidAuthProvider();
|
||||||
|
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
|
||||||
|
configManager.Object,
|
||||||
|
appHost.Object);
|
||||||
|
|
||||||
|
_userManager = new UserManager(
|
||||||
|
factory.Object,
|
||||||
|
new NoopEventManager(),
|
||||||
|
new Mock<INetworkManager>().Object,
|
||||||
|
appHost.Object,
|
||||||
|
new Mock<IImageProcessor>().Object,
|
||||||
|
NullLogger<UserManager>.Instance,
|
||||||
|
configManager.Object,
|
||||||
|
new IPasswordResetProvider[] { defaultPasswordResetProvider },
|
||||||
|
new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_userManager.Dispose();
|
||||||
|
_connection.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JellyfinDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
return new JellyfinDbContext(
|
||||||
|
_dbOptions,
|
||||||
|
NullLogger<JellyfinDbContext>.Instance,
|
||||||
|
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||||
|
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- GetUserByName tests -----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// German umlauts
|
||||||
|
[InlineData("münchen", "MÜNCHEN")]
|
||||||
|
// Spanish tilde-n
|
||||||
|
[InlineData("Ñoño", "ÑOÑO")]
|
||||||
|
// ASCII, invariant uppercase lookup
|
||||||
|
[InlineData("jellyfin", "JELLYFIN")]
|
||||||
|
// Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130)
|
||||||
|
[InlineData("Çelebi", "ÇELEBI")]
|
||||||
|
public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName(
|
||||||
|
string username, string normalizedLookup)
|
||||||
|
{
|
||||||
|
await _userManager.CreateUserAsync(username);
|
||||||
|
|
||||||
|
var found = _userManager.GetUserByName(normalizedLookup);
|
||||||
|
|
||||||
|
Assert.NotNull(found);
|
||||||
|
Assert.Equal(username, found.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// German umlaut, look up by both upper and lower case
|
||||||
|
[InlineData("münchen")]
|
||||||
|
// Spanish tilde-n
|
||||||
|
[InlineData("Ñoño")]
|
||||||
|
// lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ'
|
||||||
|
[InlineData("ali")]
|
||||||
|
// mixed ASCII + umlaut
|
||||||
|
[InlineData("testüser")]
|
||||||
|
public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username)
|
||||||
|
{
|
||||||
|
await _userManager.CreateUserAsync(username);
|
||||||
|
|
||||||
|
var upperFound = _userManager.GetUserByName(username.ToUpperInvariant());
|
||||||
|
var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant());
|
||||||
|
var exactFound = _userManager.GetUserByName(username);
|
||||||
|
|
||||||
|
Assert.NotNull(upperFound);
|
||||||
|
Assert.NotNull(lowerFound);
|
||||||
|
Assert.NotNull(exactFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("nonexistent")]
|
||||||
|
// No user with NormalizedUsername = "MÜNCHEN" has been created
|
||||||
|
[InlineData("MÜNCHEN")]
|
||||||
|
public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName)
|
||||||
|
{
|
||||||
|
var result = _userManager.GetUserByName(lookupName);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- CreateUserAsync duplicate detection tests -----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// German umlaut, case-swapped duplicate
|
||||||
|
[InlineData("münchen", "MÜNCHEN")]
|
||||||
|
// Spanish tilde-n, lowercase duplicate
|
||||||
|
[InlineData("Ñoño", "ñoño")]
|
||||||
|
// ASCII, uppercase duplicate
|
||||||
|
[InlineData("alice", "ALICE")]
|
||||||
|
// Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant()
|
||||||
|
[InlineData("çelebi", "ÇELEBI")]
|
||||||
|
public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException(
|
||||||
|
string existingUsername, string duplicateUsername)
|
||||||
|
{
|
||||||
|
await _userManager.CreateUserAsync(existingUsername);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(
|
||||||
|
() => _userManager.CreateUserAsync(duplicateUsername));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Different non-ASCII names that do not collide after normalization
|
||||||
|
[InlineData("münchen", "münchen2")]
|
||||||
|
[InlineData("ali", "ali2")]
|
||||||
|
// Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E)
|
||||||
|
[InlineData("noño", "nono")]
|
||||||
|
public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers(
|
||||||
|
string firstUsername, string secondUsername)
|
||||||
|
{
|
||||||
|
var first = await _userManager.CreateUserAsync(firstUsername);
|
||||||
|
var second = await _userManager.CreateUserAsync(secondUsername);
|
||||||
|
|
||||||
|
Assert.NotNull(first);
|
||||||
|
Assert.NotNull(second);
|
||||||
|
Assert.NotEqual(first.Id, second.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- RenameUser tests -----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Rename to non-ASCII name
|
||||||
|
[InlineData("alice", "münchen")]
|
||||||
|
// Rename between similar non-ASCII and ASCII
|
||||||
|
[InlineData("müller", "mueller")]
|
||||||
|
// Contains 'i': invariant uppercase is always 'I', never Turkish 'İ'
|
||||||
|
[InlineData("ali", "ALI2")]
|
||||||
|
// Rename to Spanish tilde-n name
|
||||||
|
[InlineData("testuser", "Ñoño")]
|
||||||
|
public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant(
|
||||||
|
string originalName, string newName)
|
||||||
|
{
|
||||||
|
var user = await _userManager.CreateUserAsync(originalName);
|
||||||
|
|
||||||
|
await _userManager.RenameUser(user.Id, originalName, newName);
|
||||||
|
|
||||||
|
var renamed = _userManager.GetUserById(user.Id);
|
||||||
|
Assert.NotNull(renamed);
|
||||||
|
Assert.Equal(newName, renamed.Username);
|
||||||
|
Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Same name different case: NormalizedUsername already taken
|
||||||
|
[InlineData("münchen", "MÜNCHEN")]
|
||||||
|
// Spanish, lowercase conflicts with existing uppercase-normalised entry
|
||||||
|
[InlineData("Ñoño", "ñoño")]
|
||||||
|
// ASCII, capitalised conflict
|
||||||
|
[InlineData("alice", "Alice")]
|
||||||
|
// Mixed ASCII + umlaut
|
||||||
|
[InlineData("testüser", "TESTÜSER")]
|
||||||
|
public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException(
|
||||||
|
string existingUsername, string conflictingNewName)
|
||||||
|
{
|
||||||
|
var targetUser = await _userManager.CreateUserAsync("renametarget");
|
||||||
|
await _userManager.CreateUserAsync(existingUsername);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(
|
||||||
|
() => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopEventManager : IEventManager
|
||||||
|
{
|
||||||
|
public void Publish<T>(T eventArgs)
|
||||||
|
where T : EventArgs
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PublishAsync<T>(T eventArgs)
|
||||||
|
where T : EventArgs
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user