Compare commits

..

99 Commits

Author SHA1 Message Date
Joshua M. Boniface
b2aa80ce5c Fix invalid regex comparison 2026-03-31 19:59:33 -04:00
Joshua M. Boniface
ff365dae34 Fix invalid merge conflict fix 2026-03-31 19:46:47 -04:00
Jellyfin Release Bot
52aebfb7d3 Bump version to 10.11.7 2026-03-31 19:33:11 -04:00
Joshua M. Boniface
66ea1b50e6 Merge commit from fork
Fix GHSA-8fw7-f233-ffr8 with improved sanitization
2026-03-31 19:17:17 -04:00
Joshua M. Boniface
3f656ade7a Merge remote-tracking branch 'upstream/release-10.11.z' into advisory-fix-1 2026-03-31 19:16:19 -04:00
Joshua M. Boniface
8bf0d372c6 Merge commit from fork
Fix GHSA-jh22-fw8w-2v9x
2026-03-31 17:46:01 -04:00
Joshua M. Boniface
202d7b5829 Merge branch 'release-10.11.z' into advisory-fix-1 2026-03-31 17:44:59 -04:00
Joshua M. Boniface
352e4f3aba Merge commit from fork
Fix GHSA v2jv-54xj-h76w
2026-03-31 17:43:02 -04:00
Joshua M. Boniface
c5f6d00c94 Merge commit from fork
Fix GHSA-j2hf-x4q5-47j3 with improved sanitization
2026-03-31 17:38:46 -04:00
Shadowghost
e8d1d94436 Lock down tuner API to be admin-only 2026-03-31 16:35:15 +02:00
Shadowghost
50dc37065b Fix GHSA-jh22-fw8w-2v9x 2026-03-31 09:30:45 +02:00
Niels van Velzen
7e88b18192 Merge pull request #16522 from Bond-009/CA1810
Fix CA1810 build error
2026-03-30 18:44:15 +02:00
Bond-009
89e914c7f1 Merge pull request #16519 from jellyfin/check-h264-profile-null
Fix Null was not checked before using the H264 profile
2026-03-30 18:39:45 +02:00
Bond_009
1932ac4765 Fix CA1810 build error 2026-03-30 18:33:56 +02:00
Bond-009
ec33c74ec4 Merge pull request #16440 from Molier/fix/subtitle-extraction-flush
Remove -copyts and add -flush_packets 1 to subtitle extraction
2026-03-30 18:30:58 +02:00
nyanmisaka
2184ed1b16 Fix Null was not checked before using the H264 profile
This is rare, but not impossible.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-03-30 20:51:11 +08:00
Shadowghost
d3907afde7 Add additional validations 2026-03-30 10:48:51 +02:00
theguymadmax
e12d933531 Revet lint fix 2026-03-30 04:21:26 -04:00
theguymadmax
c0ba29d917 fix lint issue 2026-03-30 04:14:23 -04:00
Shadowghost
d1fd81c382 Fix GHSA v2jv-54xj-h76w 2026-03-30 09:40:01 +02:00
Joshua M. Boniface
e038045494 Fix lint 2026-03-29 19:11:40 -04:00
Joshua M. Boniface
e1691e649e Merge pull request #16514 from theguymadmax/release-10.11.z-fixup 2026-03-29 19:04:53 -04:00
theguymadmax
8d28497d29 Fix lint issue 2026-03-29 18:25:57 -04:00
Joshua M. Boniface
fddd4e7e6b Fix GHSA-8fw7-f233-ffr8 with improved sanitization
Co-Authored-By: Shadowghost <Ghost_of_Stone@web.de>
2026-03-29 17:30:09 -04:00
Joshua M. Boniface
0581cd6610 Fix GHSA-j2hf-x4q5-47j3 with improved sanitization
Co-Authored-By: Shadowghost <Ghost_of_Stone@web.de>
2026-03-29 17:22:14 -04:00
Joshua M. Boniface
0f1732e5f5 Merge pull request #16425 from theguymadmax/fix-metadata-backup 2026-03-29 15:22:14 -04:00
Bond-009
41c2d51d8c Merge pull request #16369 from Bond-009/skiafonts
Fix nullref ex in font handling
2026-03-29 20:27:17 +02:00
Bond-009
29b2361857 Merge pull request #16423 from nyanmisaka/fix-ffmpeg8-readrate-option
Fix readrate options in FFmpeg 8.1
2026-03-23 21:25:35 +01:00
Bond-009
ce867f9834 Merge pull request #16449 from theguymadmax/fix-collection-number
Fix NFO saver using wrong provider ID for collectionnumber
2026-03-23 19:23:34 +01:00
theguymadmax
4034bf9d7e Save collection id instead of moive id 2026-03-22 12:49:33 -04:00
Oscar
3d2658fa43 Remove -copyts and add -flush_packets 1 to subtitle extraction
-copyts is unnecessary for -c:s copy to SRT and slows extraction ~5x.
Without -flush_packets 1, ffmpeg buffers all output until exit, leaving
.srt files at 0 bytes for minutes while the player shows no subtitles.

Fixes #16438
2026-03-19 22:54:05 +01:00
theguymadmax
61b19688ff Backup default metadata location 2026-03-17 23:13:20 -04:00
theguymadmax
e8d72bf6a3 Fix restore backup metadata location 2026-03-16 10:32:09 -04:00
nyanmisaka
348b14f7b7 Fix readrate options in FFmpeg 8.1
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-03-16 17:58:53 +08:00
IceStormNG
fda49a5a49 Apply analyzeduration and probesize for subtitle streams to improve codec parameter detection (#16293)
Apply analyzeduration and probesize for subtitle streams to improve codec parameter detection
2026-03-13 20:26:25 +01:00
Bond-009
55c00d76bb Merge pull request #16392 from nyanmisaka/fix-ffmpeg8-filter-detection
Fix filter detection in FFmpeg 8.1
2026-03-13 20:24:18 +01:00
nyanmisaka
519d2113eb Fix filter detection in FFmpeg 8.1
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-03-11 17:46:07 +08:00
Bond_009
f34f6b6941 Fix nullref ex in font handling
Don't add fallback fonts if they are null
The default font can still be null, however unlikely
2026-03-08 12:46:25 +01:00
Joshua M. Boniface
6864e108b8 Merge pull request #16257 from lowbit/fix/subtitle-empty-file-cache 2026-03-07 00:53:56 -05:00
Bond-009
09ba04662a Merge pull request #16341 from crimsonspecter/fix-fractional-hls-time-for-remux
Fix hls segment length adjustment for remuxed content
2026-03-06 22:39:59 +01:00
crimsonspecter
9cd2418095 Fix: don't apply segment length adjustment for remuxed content 2026-03-04 20:27:06 +01:00
Bond-009
b6a96513de Merge pull request #16253 from theguymadmax/use-backupdatabase
Checkpoint WAL before moving library.db in migration
2026-02-28 10:08:36 +01:00
Bond-009
ca57166e95 Merge pull request #16204 from MBR-0001/fix-bad-subtitle-settings
Fix broken library subtitle download settings
2026-02-28 10:08:20 +01:00
MBR#0001
33496c1693 Fix broken library subtitle download settings 2026-02-26 19:54:24 +01:00
Bond-009
b65daeca0b Merge pull request #16150 from dfederm/nullref-season-series
Fix nullref in Season.GetEpisodes when the season is detached from a series
2026-02-22 13:49:41 +01:00
David Federman
286cc6d720 Fix nullref in Season.GetEpisodes when the season is detached from a series 2026-02-20 22:56:30 -08:00
Andrew Rabert
aa4f09c799 Mitigate pull_request_target privilege escalation
Hotfix — replaces pull_request_target with pull_request to stop
granting write permissions and secrets to fork PRs. Some workflows
will break; can be fixed properly later.
2026-02-20 19:10:40 -05:00
rijads
afd3c0d9f3 Fix subtitle extraxtion caching empty files 2026-02-19 17:53:47 +01:00
theguymadmax
5597d8e1a7 Checkpoint wal 2026-02-18 15:11:57 -05:00
theguymadmax
0166362258 Use BackupDatabase() instead of File.Move in library.db migration 2026-02-17 22:56:45 -05:00
Bond-009
58c330b63d Merge pull request #16226 from dfederm/fix/16134-migration-unique-constraint
Deduplicate provider IDs during MigrateLibraryDb migration
2026-02-14 11:46:43 +01:00
Bond-009
be71295693 Merge pull request #16227 from dfederm/fix/16149-watch-state-episode-replace
Reattach user data after item removal during library scan
2026-02-14 11:45:51 +01:00
Bond-009
8cd3090cee Merge pull request #16231 from theguymadmax/skip-image-for-empty-folders
Skip image checks for empty folders
2026-02-14 11:43:24 +01:00
David Federman
7bf08daeec Reattach user data after removing items during library scan
When items are removed during a library scan, their user data is
detached to a placeholder. If a replacement item already exists
(e.g., a new version of the same episode was added before the old
file was deleted), the user data would be stranded in the placeholder
because the replacement item's initial ReattachUserDataAsync call
happened before the old item was deleted.

This fix checks for remaining valid children that share user data
keys with removed items and reattaches any detached user data to them.

Fixes #16149
2026-02-12 20:38:28 -08:00
David Federman
290463fe7b Fix migration UNIQUE constraint on BaseItemProviders
Deduplicate ProviderIds by ProviderId during MigrateLibraryDb migration
to prevent UNIQUE constraint violations when legacy data contains
duplicate provider entries for the same item.

Fixes #16134
2026-02-12 20:37:26 -08:00
theguymadmax
1b2d9c100a Skip image checks for empty folders 2026-02-12 18:04:27 -05:00
Bond-009
caa05c1bf2 Merge pull request #16116 from saltpi/bugfix
Fix TMDB image URLs missing size parameter
2026-02-02 20:48:21 +01:00
Niels van Velzen
a37ead86df Merge pull request #16098 from theguymadmax/fix-random-sort
Fix random sort returning duplicate items
2026-01-27 11:31:41 +01:00
Niels van Velzen
e65aff8bc6 Merge pull request #16109 from nielsvanvelzen/ws-session-info-dto-backport
Fix SessionInfoWebSocketListener not using SessionInfoDto
2026-01-27 11:31:15 +01:00
endpne
9734494eb6 Fix TMDB image URLs missing size parameter 2026-01-26 13:25:03 +08:00
Niels van Velzen
d41e302418 Fix SessionInfoWebSocketListener not using SessionInfoDto 2026-01-25 21:21:48 +01:00
theguymadmax
80ba517294 Fix random sort returning duplicate items 2026-01-24 13:48:05 -05:00
MarcoCoreDuo
95d08b264f Rehydrate cached UserData after reattachment (#16071) 2026-01-22 17:43:05 -07:00
IceStormNG
893a849f28 Slightly adjust segment length for fractional framerates (#16053)
Co-authored-by: Carsten Braun <carsten.braun@braun-cloud.de>
2026-01-22 17:41:51 -07:00
theguymadmax
673f617994 Fix TMDB crew department mapping (#16066) 2026-01-22 17:40:35 -07:00
theguymadmax
644327eb76 Revert hidden directory ignore pattern (#16077) 2026-01-22 17:39:55 -07:00
Jellyfin Release Bot
10662e75e4 Bump version to 10.11.6 2026-01-18 20:02:59 -05:00
Joshua M. Boniface
a2b1936e73 Merge pull request #15816 from theguymadmax/preserve-artist-order
Fix artist display order
2026-01-18 19:48:17 -05:00
theguymadmax
2df546af6d Deduplicate using Distinct 2026-01-18 18:16:45 -05:00
Claus Vium
338b480217 Merge pull request #16046 from theguymadmax/restore-weekly-images
Restore weekly refresh for library folder images
2026-01-18 16:36:45 +01:00
theguymadmax
2943bb6fdd Restore collection folder image refresh 2026-01-18 01:51:51 -05:00
theguymadmax
94edcbd2d1 Fix artist ordering DtoServices 2026-01-17 10:14:41 -05:00
theguymadmax
a8d1cdefac Address review comments 2026-01-17 10:14:41 -05:00
Tim Eisele
a518160a6f Prioritize better matches on search (#15983) 2026-01-16 19:05:46 -07:00
Tim Eisele
b56de6493f Be more strict about PersonType assignments (#15872) 2026-01-16 19:03:13 -07:00
theguymadmax
093cfc3f3b Trim music artist names (#15808) 2026-01-16 18:51:48 -07:00
theguymadmax
49775b1f6a Fix birthplace not saving correctly (#16020) 2026-01-16 18:47:40 -07:00
Collin T Swisher
22d593b8e9 Add mblink creation logic to library update endpoint. (#15965) 2026-01-16 18:47:04 -07:00
theguymadmax
2cb7fb52d2 Skip hidden directories and .ignore paths in library monitoring (#16029) 2026-01-16 18:45:19 -07:00
Joshua M. Boniface
8433b6d8a4 Merge pull request #15899 from MarcoCoreDuo/fix-watch-state-not-kept
Fix watched state not kept on Media replace/rename
2026-01-16 16:40:36 -05:00
Bond-009
32d2414de0 Merge pull request #15950 from theguymadmax/revert-sort-index-number
Revert "always sort season by index number"
2026-01-09 18:38:23 +01:00
Bond-009
317a3a47c3 Merge pull request #15961 from theguymadmax/fix-bad-plugin-url
Fix crash when plugin repository has an invalid URL
2026-01-09 18:24:48 +01:00
theguymadmax
845b8cdc8f Fix crash when plugin repository has an invalid URL 2026-01-06 11:57:25 -05:00
theguymadmax
c86f6439c5 Revert "always sort season by index number"
This reverts commit e16ea7b236.
2026-01-05 11:06:25 -05:00
theguymadmax
559e0088e5 Fix tag inheritance for Continue Watching queries (#15931) 2026-01-04 11:20:34 -07:00
MarcoCoreDuo
adaca95590 make db context creation async 2025-12-31 07:43:07 +01:00
MarcoCoreDuo
09a1c31fa3 Refactor ReattachUserData methods to be asynchronous 2025-12-31 03:06:07 +01:00
MarcoCoreDuo
e4b82025b8 move reattaching user data to own function and call it only after fetching metadata for the first time 2025-12-30 22:04:59 +01:00
Collin T Swisher
78e3702cb0 Fix playlist item de-duplication (#15858) 2025-12-24 07:50:15 -07:00
Bond-009
01b20d3b75 Merge pull request #15833 from nyanmisaka/fix-h264-av1-sdr-hls-fallback
Fix missing H.264 and AV1 SDR fallbacks in HLS playlist
2025-12-24 10:28:33 +01:00
Tim Eisele
156761405e Prefer US rating on fallback (#15793) 2025-12-19 20:41:09 -07:00
Claus Vium
1805f2259f add CultureDto cache (#15826) 2025-12-19 20:38:54 -07:00
Nyanmisaka
4c587776d6 Fix the use of HWA in unsupported H.264 Hi422P/Hi444PP (#15819) 2025-12-19 19:58:56 -07:00
gnattu
8379b4634a Enforce more strict webm check (#15807) 2025-12-19 19:57:08 -07:00
Nyanmisaka
9470439cfa Fix video lacking SAR and DAR are marked as anamorphic (#15834) 2025-12-19 19:54:48 -07:00
gnattu
18096e48e0 Use hvc1 codectag for Dolby Vision 8.4 (#15835) 2025-12-19 19:53:28 -07:00
nyanmisaka
f2d0ac7b28 Fix missing H.264 and AV1 SDR fallbacks in HLS playlist
Previously, if HEVC encoding was disabled on the server,
SDR fallbacks would not be provided.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2025-12-19 20:33:24 +08:00
theguymadmax
2ccf08f547 Fix artist display order 2025-12-17 01:09:13 -05:00
Jellyfin Release Bot
1e27f460fe Bump version to 10.11.5 2025-12-14 21:44:14 -05:00
63 changed files with 1185 additions and 498 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -267,22 +267,24 @@ 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)
{ {
if (!image.IsLocalFile) return GetItemsWithImages(item).Count is not 0;
{ }
return false;
}
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path)) if (!image.IsLocalFile)
{ {
return false; return false;
} }
if (!HasChangedByDate(item, image)) if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
{ {
return false; return false;
} }
if (!HasChangedByDate(item, image))
{
return false;
} }
return true; return true;

View File

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

View File

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

View File

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

View File

@@ -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)
|| language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
{
return culture;
}
} }
return default; return _cultureCache.GetOrAdd(
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 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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.VideoRange == VideoRange.HDR
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
{ {
var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0) && 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)
&& state.VideoStream.VideoRange == VideoRange.HDR)
{ {
// Force HEVC Main Profile and disable video stream copy. // Force AV1 and HEVC Main Profile and disable video stream copy.
state.OutputVideoCodec = "hevc"; state.OutputVideoCodec = sdrVideoCodec;
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
sdrVideoUrl += "&AllowVideoStreamCopy=false"; var sdrPlaylistQuery = playlistQuery;
sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
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();

View File

@@ -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:
request.AudioCodec = val; if (IsValidCodecName(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:
request.SubtitleCodec = val; if (IsValidCodecName(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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -275,8 +275,9 @@ public sealed class BaseItemRepository
} }
dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
result.Items = GetEntities(dbQuery, context, filter).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
result.StartIndex = filter.StartIndex ?? 0; result.StartIndex = filter.StartIndex ?? 0;
return result; return result;
} }
@@ -295,7 +296,27 @@ public sealed class BaseItemRepository
dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter);
return GetEntities(dbQuery, context, filter).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); 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);
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -337,7 +358,9 @@ public sealed class BaseItemRepository
mainquery = ApplyGroupingFilter(context, mainquery, filter); mainquery = ApplyGroupingFilter(context, mainquery, filter);
mainquery = ApplyQueryPaging(mainquery, filter); mainquery = ApplyQueryPaging(mainquery, filter);
return GetEntities(mainquery, context, filter).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); mainquery = ApplyNavigations(mainquery, filter);
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -404,6 +427,36 @@ public sealed class BaseItemRepository
return dbQuery; return dbQuery;
} }
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
{
dbQuery = dbQuery.Include(e => e.TrailerTypes);
}
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
{
dbQuery = dbQuery.Include(e => e.Provider);
}
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
{
dbQuery = dbQuery.Include(e => e.LockedFields);
}
if (filter.DtoOptions.EnableUserData)
{
dbQuery = dbQuery.Include(e => e.UserData);
}
if (filter.DtoOptions.EnableImages)
{
dbQuery = dbQuery.Include(e => e.Images);
}
return dbQuery;
}
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{ {
if (filter.Limit.HasValue || filter.StartIndex.HasValue) if (filter.Limit.HasValue || filter.StartIndex.HasValue)
@@ -429,6 +482,7 @@ public sealed class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery; return dbQuery;
} }
@@ -440,79 +494,6 @@ public sealed class BaseItemRepository
return dbQuery; return dbQuery;
} }
private IReadOnlyList<BaseItemEntity> GetEntities(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter)
{
var items = dbQuery.Where(e => e != null).ToDictionary(e => e.Id, e => e);
var itemIds = items.Keys.ToArray();
if (itemIds.Length == 0)
{
return [];
}
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
{
var values = context.BaseItemTrailerTypes.WhereOneOrMany(itemIds, e => e.ItemId).GroupBy(x => x.ItemId).ToArray();
foreach (var value in values)
{
if (items.TryGetValue(value.Key, out var item))
{
item.TrailerTypes = value.ToArray();
}
}
}
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
{
var values = context.BaseItemProviders.WhereOneOrMany(itemIds, e => e.ItemId).GroupBy(x => x.ItemId).ToArray();
foreach (var value in values)
{
if (items.TryGetValue(value.Key, out var item))
{
item.Provider = value.ToArray();
}
}
}
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
{
var values = context.BaseItemMetadataFields.WhereOneOrMany(itemIds, e => e.ItemId).GroupBy(x => x.ItemId).ToArray();
foreach (var value in values)
{
if (items.TryGetValue(value.Key, out var item))
{
item.LockedFields = value.ToArray();
}
}
}
if (filter.DtoOptions.EnableImages)
{
var values = context.BaseItemImageInfos.WhereOneOrMany(itemIds, e => e.ItemId).GroupBy(x => x.ItemId).ToArray();
foreach (var value in values)
{
if (items.TryGetValue(value.Key, out var item))
{
item.Images = value.ToArray();
}
}
}
if (filter.DtoOptions.EnableUserData)
{
var values = context.UserData.WhereOneOrMany(itemIds, e => e.ItemId).GroupBy(x => x.ItemId).ToArray();
foreach (var value in values)
{
if (items.TryGetValue(value.Key, out var item))
{
item.UserData = value.ToArray();
}
}
}
return items.Values.ToArray();
}
/// <inheritdoc/> /// <inheritdoc/>
public int GetCount(InternalItemsQuery filter) public int GetCount(InternalItemsQuery filter)
{ {
@@ -655,7 +636,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)
{ {
@@ -689,19 +669,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();
@@ -797,6 +764,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)
{ {
@@ -904,7 +908,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('|');
@@ -1066,7 +1070,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
@@ -1596,29 +1600,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);
}
} }
} }
@@ -2485,35 +2496,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 && includeTags.Contains(f.ItemValue.CleanValue))
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.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)
@@ -2667,6 +2667,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;
} }
} }

View File

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

View File

@@ -0,0 +1,106 @@
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.EntityFrameworkCore;
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;
}
}

View File

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

View File

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

View File

@@ -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))
{ {
info.IsRemote = true; var shortcutProtocol = MediaSourceManager.GetPathProtocol(video.ShortcutPath);
info.Path = video.ShortcutPath;
info.Protocol = MediaSourceManager.GetPathProtocol(info.Path); // 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.Path = video.ShortcutPath;
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
@@ -476,7 +484,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 +525,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 +743,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 +1275,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))
@@ -1768,38 +1790,40 @@ 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))
{ {
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)) return null;
}
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
{
// Transcode to level 5.3 (15) and lower for maximum compatibility.
// https://en.wikipedia.org/wiki/AV1#Levels
if (requestLevel < 0 || requestLevel >= 15)
{ {
// Transcode to level 5.3 (15) and lower for maximum compatibility. return "15";
// https://en.wikipedia.org/wiki/AV1#Levels
if (requestLevel < 0 || requestLevel >= 15)
{
return "15";
}
} }
else if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) }
|| string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)) else if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase))
{
// Transcode to level 5.0 and lower for maximum compatibility.
// Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it.
// https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
// MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880.
if (requestLevel < 0 || requestLevel >= 150)
{ {
// Transcode to level 5.0 and lower for maximum compatibility. return "150";
// Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it.
// https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
// MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880.
if (requestLevel < 0 || requestLevel >= 150)
{
return "150";
}
} }
else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) }
else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
{
// Transcode to level 5.1 and lower for maximum compatibility.
// h264 4k 30fps requires at least level 5.1 otherwise it will break on safari fmp4.
// https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels
if (requestLevel < 0 || requestLevel >= 51)
{ {
// Transcode to level 5.1 and lower for maximum compatibility. return "51";
// h264 4k 30fps requires at least level 5.1 otherwise it will break on safari fmp4.
// https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels
if (requestLevel < 0 || requestLevel >= 51)
{
return "51";
}
} }
} }
@@ -2189,12 +2213,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 +6381,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 +7138,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 +7155,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 +7183,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 +7227,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 +7241,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>();

View File

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

View File

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

View File

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

View File

@@ -299,9 +299,12 @@ 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.
|| (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase)))) // 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))))
{ {
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;
} }

View File

@@ -321,7 +321,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);
} }
@@ -423,9 +423,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 +512,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 +569,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 +594,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 +613,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 +639,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 +726,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 +782,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 +884,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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
using System.Reflection; using System.Reflection;
[assembly: AssemblyVersion("10.11.4")] [assembly: AssemblyVersion("10.11.7")]
[assembly: AssemblyFileVersion("10.11.4")] [assembly: AssemblyFileVersion("10.11.7")]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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