mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-04-03 08:51:56 +01:00
Compare commits
99 Commits
explicit-l
...
v10.11.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2aa80ce5c | ||
|
|
ff365dae34 | ||
|
|
52aebfb7d3 | ||
|
|
66ea1b50e6 | ||
|
|
3f656ade7a | ||
|
|
8bf0d372c6 | ||
|
|
202d7b5829 | ||
|
|
352e4f3aba | ||
|
|
c5f6d00c94 | ||
|
|
e8d1d94436 | ||
|
|
50dc37065b | ||
|
|
7e88b18192 | ||
|
|
89e914c7f1 | ||
|
|
1932ac4765 | ||
|
|
ec33c74ec4 | ||
|
|
2184ed1b16 | ||
|
|
d3907afde7 | ||
|
|
e12d933531 | ||
|
|
c0ba29d917 | ||
|
|
d1fd81c382 | ||
|
|
e038045494 | ||
|
|
e1691e649e | ||
|
|
8d28497d29 | ||
|
|
fddd4e7e6b | ||
|
|
0581cd6610 | ||
|
|
0f1732e5f5 | ||
|
|
41c2d51d8c | ||
|
|
29b2361857 | ||
|
|
ce867f9834 | ||
|
|
4034bf9d7e | ||
|
|
3d2658fa43 | ||
|
|
61b19688ff | ||
|
|
e8d72bf6a3 | ||
|
|
348b14f7b7 | ||
|
|
fda49a5a49 | ||
|
|
55c00d76bb | ||
|
|
519d2113eb | ||
|
|
f34f6b6941 | ||
|
|
6864e108b8 | ||
|
|
09ba04662a | ||
|
|
9cd2418095 | ||
|
|
b6a96513de | ||
|
|
ca57166e95 | ||
|
|
33496c1693 | ||
|
|
b65daeca0b | ||
|
|
286cc6d720 | ||
|
|
aa4f09c799 | ||
|
|
afd3c0d9f3 | ||
|
|
5597d8e1a7 | ||
|
|
0166362258 | ||
|
|
58c330b63d | ||
|
|
be71295693 | ||
|
|
8cd3090cee | ||
|
|
7bf08daeec | ||
|
|
290463fe7b | ||
|
|
1b2d9c100a | ||
|
|
caa05c1bf2 | ||
|
|
a37ead86df | ||
|
|
e65aff8bc6 | ||
|
|
9734494eb6 | ||
|
|
d41e302418 | ||
|
|
80ba517294 | ||
|
|
95d08b264f | ||
|
|
893a849f28 | ||
|
|
673f617994 | ||
|
|
644327eb76 | ||
|
|
10662e75e4 | ||
|
|
a2b1936e73 | ||
|
|
2df546af6d | ||
|
|
338b480217 | ||
|
|
2943bb6fdd | ||
|
|
94edcbd2d1 | ||
|
|
a8d1cdefac | ||
|
|
a518160a6f | ||
|
|
b56de6493f | ||
|
|
093cfc3f3b | ||
|
|
49775b1f6a | ||
|
|
22d593b8e9 | ||
|
|
2cb7fb52d2 | ||
|
|
8433b6d8a4 | ||
|
|
32d2414de0 | ||
|
|
317a3a47c3 | ||
|
|
845b8cdc8f | ||
|
|
c86f6439c5 | ||
|
|
559e0088e5 | ||
|
|
adaca95590 | ||
|
|
09a1c31fa3 | ||
|
|
e4b82025b8 | ||
|
|
78e3702cb0 | ||
|
|
01b20d3b75 | ||
|
|
156761405e | ||
|
|
1805f2259f | ||
|
|
4c587776d6 | ||
|
|
8379b4634a | ||
|
|
9470439cfa | ||
|
|
18096e48e0 | ||
|
|
f2d0ac7b28 | ||
|
|
2ccf08f547 | ||
|
|
1e27f460fe |
@@ -207,6 +207,7 @@
|
|||||||
- [TokerX](https://github.com/TokerX)
|
- [TokerX](https://github.com/TokerX)
|
||||||
- [GeneMarks](https://github.com/GeneMarks)
|
- [GeneMarks](https://github.com/GeneMarks)
|
||||||
- [martenumberto](https://github.com/martenumberto)
|
- [martenumberto](https://github.com/martenumberto)
|
||||||
|
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
|
||||||
|
|
||||||
# Emby Contributors
|
# Emby Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Naming</PackageId>
|
<PackageId>Jellyfin.Naming</PackageId>
|
||||||
<VersionPrefix>10.11.4</VersionPrefix>
|
<VersionPrefix>10.11.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>
|
||||||
|
|||||||
@@ -1051,16 +1051,16 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||||
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
|
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
.Where(e => e.Value.Length > 0)
|
|
||||||
.Select(i =>
|
dto.ArtistItems = hasArtist.Artists
|
||||||
{
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
return new NameGuidPair
|
.Distinct()
|
||||||
{
|
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
|
||||||
Name = i.Key,
|
? new NameGuidPair { Name = name, Id = artists[0].Id }
|
||||||
Id = i.Value.First().Id
|
: null)
|
||||||
};
|
.Where(item => item is not null)
|
||||||
}).Where(i => i is not null).ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||||
@@ -1085,31 +1085,16 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
// })
|
// })
|
||||||
// .ToList();
|
// .ToList();
|
||||||
|
|
||||||
|
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
|
|
||||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||||
// .Except(foundArtists, new DistinctNameComparer())
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
.Select(i =>
|
.Distinct()
|
||||||
{
|
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
|
||||||
// This should not be necessary but we're seeing some cases of it
|
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
|
||||||
if (string.IsNullOrEmpty(i))
|
: null)
|
||||||
{
|
.Where(item => item is not null)
|
||||||
return null;
|
.ToArray();
|
||||||
}
|
|
||||||
|
|
||||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
|
||||||
{
|
|
||||||
EnableImages = false
|
|
||||||
});
|
|
||||||
if (artist is not null)
|
|
||||||
{
|
|
||||||
return new NameGuidPair
|
|
||||||
{
|
|
||||||
Name = artist.Name,
|
|
||||||
Id = artist.Id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}).Where(i => i is not null).ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add video info
|
// Add video info
|
||||||
|
|||||||
@@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||||
|
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||||
foreach (var i in _tempIgnoredPaths.Keys)
|
foreach (var i in _tempIgnoredPaths.Keys)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -267,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;
|
||||||
|
|||||||
@@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images
|
|||||||
|
|
||||||
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
|
||||||
|
{
|
||||||
|
var age = DateTime.UtcNow - image.DateModified;
|
||||||
|
return age.TotalDays > 7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
"**/lost+found",
|
"**/lost+found",
|
||||||
"**/subs/**",
|
"**/subs/**",
|
||||||
"**/subs",
|
"**/subs",
|
||||||
|
"**/.snapshots/**",
|
||||||
|
"**/.snapshots",
|
||||||
|
"**/.snapshot/**",
|
||||||
|
"**/.snapshot",
|
||||||
|
|
||||||
// Trickplay files
|
// Trickplay files
|
||||||
"**/*.trickplay",
|
"**/*.trickplay",
|
||||||
|
|||||||
@@ -2202,6 +2202,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||||
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
||||||
{
|
{
|
||||||
if (item.IsFileProtocol)
|
if (item.IsFileProtocol)
|
||||||
@@ -3195,19 +3201,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||||
|
|
||||||
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
CreateShortcut(virtualFolderPath, pathInfo);
|
||||||
|
|
||||||
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
|
||||||
|
|
||||||
while (File.Exists(lnk))
|
|
||||||
{
|
|
||||||
shortcutFilename += "1";
|
|
||||||
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
|
||||||
}
|
|
||||||
|
|
||||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
|
||||||
|
|
||||||
RemoveContentTypeOverrides(path);
|
|
||||||
|
|
||||||
if (saveLibraryOptions)
|
if (saveLibraryOptions)
|
||||||
{
|
{
|
||||||
@@ -3372,5 +3366,24 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo)
|
||||||
|
{
|
||||||
|
var path = pathInfo.Path;
|
||||||
|
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||||
|
|
||||||
|
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
||||||
|
|
||||||
|
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||||
|
|
||||||
|
while (File.Exists(lnk))
|
||||||
|
{
|
||||||
|
shortcutFilename += "1";
|
||||||
|
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||||
|
RemoveContentTypeOverrides(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
||||||
return Array.Empty<PackageInfo>();
|
return Array.Empty<PackageInfo>();
|
||||||
}
|
}
|
||||||
|
catch (NotSupportedException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
|
||||||
|
return Array.Empty<PackageInfo>();
|
||||||
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
||||||
|
|||||||
@@ -92,18 +92,18 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[ProducesAudioFile]
|
[ProducesAudioFile]
|
||||||
public async Task<ActionResult> GetAudioStream(
|
public async Task<ActionResult> GetAudioStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -114,7 +114,7 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -133,8 +133,8 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -259,18 +259,18 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[ProducesAudioFile]
|
[ProducesAudioFile]
|
||||||
public async Task<ActionResult> GetAudioStreamByContainer(
|
public async Task<ActionResult> GetAudioStreamByContainer(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -281,7 +281,7 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -300,8 +300,8 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
|
|||||||
@@ -167,18 +167,18 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[ProducesPlaylistFile]
|
[ProducesPlaylistFile]
|
||||||
public async Task<ActionResult> GetLiveHlsStream(
|
public async Task<ActionResult> GetLiveHlsStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -189,7 +189,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -208,8 +208,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -416,12 +416,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery, Required] string mediaSourceId,
|
[FromQuery, Required] string mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -432,7 +432,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -453,8 +453,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -592,12 +592,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery, Required] string mediaSourceId,
|
[FromQuery, Required] string mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -609,7 +609,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -628,8 +628,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -762,12 +762,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -778,7 +778,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -799,8 +799,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -934,12 +934,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -951,7 +951,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -970,8 +970,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1107,7 +1107,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] string playlistId,
|
[FromRoute, Required] string playlistId,
|
||||||
[FromRoute, Required] int segmentId,
|
[FromRoute, Required] int segmentId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery, Required] long runtimeTicks,
|
[FromQuery, Required] long runtimeTicks,
|
||||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
@@ -1115,12 +1115,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -1131,7 +1131,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -1152,8 +1152,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1292,7 +1292,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] string playlistId,
|
[FromRoute, Required] string playlistId,
|
||||||
[FromRoute, Required] int segmentId,
|
[FromRoute, Required] int segmentId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery, Required] long runtimeTicks,
|
[FromQuery, Required] long runtimeTicks,
|
||||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
@@ -1300,12 +1300,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -1317,7 +1317,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -1336,8 +1336,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1421,10 +1421,20 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
cancellationTokenSource.Token)
|
cancellationTokenSource.Token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
||||||
|
double fps = state.TargetFramerate ?? 0.0f;
|
||||||
|
int segmentLength = state.SegmentLength * 1000;
|
||||||
|
|
||||||
|
// If video is transcoded and framerate is fractional (i.e. 23.976), we need to slightly adjust segment length
|
||||||
|
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && Math.Abs(fps - Math.Floor(fps + 0.001f)) > 0.001)
|
||||||
|
{
|
||||||
|
double nearestIntFramerate = Math.Ceiling(fps);
|
||||||
|
segmentLength = (int)Math.Ceiling(segmentLength * (nearestIntFramerate / fps));
|
||||||
|
}
|
||||||
|
|
||||||
var request = new CreateMainPlaylistRequest(
|
var request = new CreateMainPlaylistRequest(
|
||||||
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
||||||
state.MediaPath,
|
state.MediaPath,
|
||||||
state.SegmentLength * 1000,
|
segmentLength,
|
||||||
state.RunTimeTicks ?? 0,
|
state.RunTimeTicks ?? 0,
|
||||||
state.Request.SegmentContainer ?? string.Empty,
|
state.Request.SegmentContainer ?? string.Empty,
|
||||||
"hls1/main/",
|
"hls1/main/",
|
||||||
@@ -1839,8 +1849,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (isActualOutputVideoCodecHevc)
|
if (isActualOutputVideoCodecHevc)
|
||||||
{
|
{
|
||||||
// Prefer dvh1 to dvhe
|
// Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
|
||||||
args += " -tag:v:0 dvh1 -strict -2";
|
var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
|
||||||
|
args += $" -tag:v:0 {codecTag} -strict -2";
|
||||||
}
|
}
|
||||||
else if (isActualOutputVideoCodecAv1)
|
else if (isActualOutputVideoCodecAv1)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (item is IHasAlbumArtist hasAlbumArtists)
|
if (item is IHasAlbumArtist hasAlbumArtists)
|
||||||
{
|
{
|
||||||
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name);
|
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +426,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (item is IHasArtist hasArtists)
|
if (item is IHasArtist hasArtists)
|
||||||
{
|
{
|
||||||
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name);
|
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LibraryOptions options = item.GetLibraryOptions();
|
||||||
|
foreach (var mediaPath in request.LibraryOptions!.PathInfos)
|
||||||
|
{
|
||||||
|
if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_libraryManager.CreateShortcut(item.Path, mediaPath);
|
||||||
|
}
|
||||||
|
|
||||||
item.UpdateLibraryOptions(request.LibraryOptions);
|
item.UpdateLibraryOptions(request.LibraryOptions);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpPost("Tuners/{tunerId}/Reset")]
|
[HttpPost("Tuners/{tunerId}/Reset")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
|
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
|
||||||
{
|
{
|
||||||
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
|
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
|
||||||
@@ -983,7 +983,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created tuner host returned.</response>
|
/// <response code="200">Created tuner host returned.</response>
|
||||||
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
|
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
|
||||||
[HttpPost("TunerHosts")]
|
[HttpPost("TunerHosts")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
|
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
|
||||||
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
|
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
|
||||||
@@ -995,7 +995,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="204">Tuner host deleted.</response>
|
/// <response code="204">Tuner host deleted.</response>
|
||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpDelete("TunerHosts")]
|
[HttpDelete("TunerHosts")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
||||||
{
|
{
|
||||||
@@ -1028,7 +1028,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created listings provider returned.</response>
|
/// <response code="200">Created listings provider returned.</response>
|
||||||
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
|
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
|
||||||
[HttpPost("ListingProviders")]
|
[HttpPost("ListingProviders")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
|
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
|
||||||
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
|
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
|
||||||
@@ -1054,7 +1054,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="204">Listing provider deleted.</response>
|
/// <response code="204">Listing provider deleted.</response>
|
||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpDelete("ListingProviders")]
|
[HttpDelete("ListingProviders")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public ActionResult DeleteListingProvider([FromQuery] string? id)
|
public ActionResult DeleteListingProvider([FromQuery] string? id)
|
||||||
{
|
{
|
||||||
@@ -1087,7 +1087,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Available countries returned.</response>
|
/// <response code="200">Available countries returned.</response>
|
||||||
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
|
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
|
||||||
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
|
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
|
||||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesFile(MediaTypeNames.Application.Json)]
|
[ProducesFile(MediaTypeNames.Application.Json)]
|
||||||
public async Task<ActionResult> GetSchedulesDirectCountries()
|
public async Task<ActionResult> GetSchedulesDirectCountries()
|
||||||
@@ -1108,7 +1108,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Channel mapping options returned.</response>
|
/// <response code="200">Channel mapping options returned.</response>
|
||||||
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
|
||||||
[HttpGet("ChannelMappingOptions")]
|
[HttpGet("ChannelMappingOptions")]
|
||||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
|
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
|
||||||
=> _listingsManager.GetChannelMappingOptions(providerId);
|
=> _listingsManager.GetChannelMappingOptions(providerId);
|
||||||
@@ -1120,7 +1120,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created channel mapping returned.</response>
|
/// <response code="200">Created channel mapping returned.</response>
|
||||||
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
|
||||||
[HttpPost("ChannelMappings")]
|
[HttpPost("ChannelMappings")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
|
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
|
||||||
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
|
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
|
||||||
@@ -1144,7 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
|
||||||
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
|
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
|
||||||
[HttpGet("Tuners/Discover")]
|
[HttpGet("Tuners/Discover")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
|
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
|
||||||
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
|
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
|
||||||
@@ -1192,7 +1192,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public ActionResult GetLiveStreamFile(
|
public ActionResult GetLiveStreamFile(
|
||||||
[FromRoute, Required] string streamId,
|
[FromRoute, Required] string streamId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container)
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container)
|
||||||
{
|
{
|
||||||
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
|
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
|
||||||
if (liveStreamInfo is null)
|
if (liveStreamInfo is null)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public class SyncPlayController : BaseJellyfinApiController
|
|||||||
[FromBody, Required] NewGroupRequestDto requestData)
|
[FromBody, Required] NewGroupRequestDto requestData)
|
||||||
{
|
{
|
||||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||||
var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
|
var syncPlayRequest = new NewGroupRequest(requestData.GroupName.Trim());
|
||||||
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ public class UniversalAudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] Guid? userId,
|
[FromQuery] Guid? userId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] int? transcodingAudioChannels,
|
[FromQuery] int? transcodingAudioChannels,
|
||||||
[FromQuery] int? maxStreamingBitrate,
|
[FromQuery] int? maxStreamingBitrate,
|
||||||
[FromQuery] int? audioBitRate,
|
[FromQuery] int? audioBitRate,
|
||||||
[FromQuery] long? startTimeTicks,
|
[FromQuery] long? startTimeTicks,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? transcodingContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer,
|
||||||
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
||||||
[FromQuery] int? maxAudioSampleRate,
|
[FromQuery] int? maxAudioSampleRate,
|
||||||
[FromQuery] int? maxAudioBitDepth,
|
[FromQuery] int? maxAudioBitDepth,
|
||||||
|
|||||||
@@ -315,18 +315,18 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public async Task<ActionResult> GetVideoStream(
|
public async Task<ActionResult> GetVideoStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -337,7 +337,7 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -358,8 +358,8 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -556,18 +556,18 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public Task<ActionResult> GetVideoStreamByContainer(
|
public Task<ActionResult> GetVideoStreamByContainer(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery] string? deviceProfileId,
|
[FromQuery] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -578,7 +578,7 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -599,8 +599,8 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public class DynamicHlsHelper
|
|||||||
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
||||||
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
|
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
||||||
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
||||||
}
|
}
|
||||||
@@ -173,10 +173,21 @@ public class DynamicHlsHelper
|
|||||||
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main stream
|
// Video rotation metadata is only supported in fMP4 remuxing
|
||||||
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
if (state.VideoStream is not null
|
||||||
|
&& state.VideoRequest is not null
|
||||||
|
&& (state.VideoStream?.Rotation ?? 0) != 0
|
||||||
|
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
|
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||||
|
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
queryString += "&AllowVideoStreamCopy=false";
|
||||||
|
}
|
||||||
|
|
||||||
playlistUrl += queryString;
|
// Main stream
|
||||||
|
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||||
|
var playlistUrl = baseUrl + queryString;
|
||||||
|
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
|
|
||||||
var subtitleStreams = state.MediaSource
|
var subtitleStreams = state.MediaSource
|
||||||
.MediaStreams
|
.MediaStreams
|
||||||
@@ -198,37 +209,36 @@ public class DynamicHlsHelper
|
|||||||
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video rotation metadata is only supported in fMP4 remuxing
|
|
||||||
if (state.VideoStream is not null
|
|
||||||
&& state.VideoRequest is not null
|
|
||||||
&& (state.VideoStream?.Rotation ?? 0) != 0
|
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
|
||||||
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
|
||||||
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
playlistUrl += "&AllowVideoStreamCopy=false";
|
|
||||||
}
|
|
||||||
|
|
||||||
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
||||||
|
|
||||||
if (state.VideoStream is not null && state.VideoRequest is not null)
|
if (state.VideoStream is not null && state.VideoRequest is not null)
|
||||||
{
|
{
|
||||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||||
|
|
||||||
// Provide SDR HEVC entrance for backward compatibility.
|
// Provide AV1 and HEVC SDR entrances for backward compatibility.
|
||||||
if (encodingOptions.AllowHevcEncoding
|
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
|
||||||
&& !encodingOptions.AllowAv1Encoding
|
|
||||||
&& 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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Models.SyncPlayDtos;
|
namespace Jellyfin.Api.Models.SyncPlayDtos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -17,5 +19,6 @@ public class NewGroupRequestDto
|
|||||||
/// Gets or sets the group name.
|
/// Gets or sets the group name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The name of the new group.</value>
|
/// <value>The name of the new group.</value>
|
||||||
|
[StringLength(200, ErrorMessage = "Group name must not exceed 200 characters.")]
|
||||||
public string GroupName { get; set; }
|
public string GroupName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using MediaBrowser.Controller.Authentication;
|
|||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Session;
|
using MediaBrowser.Model.Session;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ namespace Jellyfin.Api.WebSocketListeners;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Class SessionInfoWebSocketListener.
|
/// Class SessionInfoWebSocketListener.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState>
|
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
|
||||||
{
|
{
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -52,24 +53,26 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
|
|||||||
/// Gets the data to send.
|
/// Gets the data to send.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>Task{SystemInfo}.</returns>
|
/// <returns>Task{SystemInfo}.</returns>
|
||||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSend()
|
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
|
||||||
{
|
{
|
||||||
return Task.FromResult(_sessionManager.Sessions);
|
return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection)
|
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSendForConnection(IWebSocketConnection connection)
|
||||||
{
|
{
|
||||||
|
var sessions = _sessionManager.Sessions;
|
||||||
|
|
||||||
// For non-admin users, filter the sessions to only include their own sessions
|
// For non-admin users, filter the sessions to only include their own sessions
|
||||||
if (connection.AuthorizationInfo?.User is not null &&
|
if (connection.AuthorizationInfo?.User is not null &&
|
||||||
!connection.AuthorizationInfo.IsApiKey &&
|
!connection.AuthorizationInfo.IsApiKey &&
|
||||||
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
|
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
|
||||||
{
|
{
|
||||||
var userId = connection.AuthorizationInfo.User.Id;
|
var userId = connection.AuthorizationInfo.User.Id;
|
||||||
return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId)));
|
sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(_sessionManager.Sessions);
|
return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Data</PackageId>
|
<PackageId>Jellyfin.Data</PackageId>
|
||||||
<VersionPrefix>10.11.4</VersionPrefix>
|
<VersionPrefix>10.11.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>
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ public static class UserEntityExtensions
|
|||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, false));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
|
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
|
||||||
|
|||||||
@@ -118,15 +118,21 @@ public class BackupService : IBackupService
|
|||||||
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
|
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
|
||||||
}
|
}
|
||||||
|
|
||||||
void CopyDirectory(string source, string target)
|
void CopyDirectory(string source, string target, string[]? exclude = null)
|
||||||
{
|
{
|
||||||
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
||||||
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
||||||
|
var excludePaths = exclude?.Select(e => $"{source}/{e}/").ToArray();
|
||||||
foreach (var item in zipArchive.Entries)
|
foreach (var item in zipArchive.Entries)
|
||||||
{
|
{
|
||||||
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
||||||
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
||||||
|
|
||||||
|
if (excludePaths is not null && excludePaths.Any(e => item.FullName.StartsWith(e, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
||||||
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|
||||||
|| Path.EndsInDirectorySeparator(item.FullName))
|
|| Path.EndsInDirectorySeparator(item.FullName))
|
||||||
@@ -142,8 +148,10 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
||||||
CopyDirectory("Data", _applicationPaths.DataPath);
|
CopyDirectory("Data", _applicationPaths.DataPath, exclude: ["metadata", "metadata-default"]);
|
||||||
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
||||||
|
CopyDirectory("Data/metadata", _applicationPaths.InternalMetadataPath);
|
||||||
|
CopyDirectory("Data/metadata-default", _applicationPaths.DefaultInternalMetadataPath);
|
||||||
|
|
||||||
if (manifest.Options.Database)
|
if (manifest.Options.Database)
|
||||||
{
|
{
|
||||||
@@ -403,6 +411,15 @@ public class BackupService : IBackupService
|
|||||||
if (backupOptions.Metadata)
|
if (backupOptions.Metadata)
|
||||||
{
|
{
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
|
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
|
||||||
|
|
||||||
|
// If a custom metadata path is configured, the default location may still contain data.
|
||||||
|
if (!string.Equals(
|
||||||
|
Path.GetFullPath(_applicationPaths.DefaultInternalMetadataPath),
|
||||||
|
Path.GetFullPath(_applicationPaths.InternalMetadataPath),
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.DefaultInternalMetadataPath), Path.Combine("Data", "metadata-default"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
|
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
|
||||||
|
|||||||
@@ -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,50 +482,18 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter)
|
private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter)
|
||||||
{
|
{
|
||||||
IQueryable<BaseItemEntity> dbQuery = context.BaseItems;
|
IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking();
|
||||||
dbQuery = dbQuery.AsSingleQuery();
|
dbQuery = dbQuery.AsSingleQuery();
|
||||||
|
|
||||||
return dbQuery;
|
return dbQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<BaseItemEntity> GetEntities(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter)
|
|
||||||
{
|
|
||||||
var items = dbQuery.AsEnumerable().Where(e => e is not null).ToArray();
|
|
||||||
var itemIds = items.Select(e => e.Id).ToArray();
|
|
||||||
|
|
||||||
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
|
|
||||||
{
|
|
||||||
context.BaseItemTrailerTypes.WhereOneOrMany(itemIds, e => e.ItemId).Load();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
|
|
||||||
{
|
|
||||||
context.BaseItemProviders.WhereOneOrMany(itemIds, e => e.ItemId).Load();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
|
|
||||||
{
|
|
||||||
context.BaseItemMetadataFields.WhereOneOrMany(itemIds, e => e.ItemId).Load();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.DtoOptions.EnableImages)
|
|
||||||
{
|
|
||||||
context.BaseItemImageInfos.WhereOneOrMany(itemIds, e => e.ItemId).Load();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.DtoOptions.EnableUserData)
|
|
||||||
{
|
|
||||||
context.UserData.WhereOneOrMany(itemIds, e => e.ItemId).Load();
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public int GetCount(InternalItemsQuery filter)
|
public int GetCount(InternalItemsQuery filter)
|
||||||
{
|
{
|
||||||
@@ -615,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)
|
||||||
{
|
{
|
||||||
@@ -649,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();
|
||||||
@@ -757,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)
|
||||||
{
|
{
|
||||||
@@ -864,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('|');
|
||||||
|
|
||||||
@@ -1026,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
|
||||||
@@ -1556,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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2445,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)
|
||||||
@@ -2627,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
// That's all the new and changed ones - now see if any have been removed and need cleanup
|
// That's all the new and changed ones - now see if any have been removed and need cleanup
|
||||||
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
|
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
|
||||||
var shouldRemove = !IsRoot || allowRemoveRoot;
|
var shouldRemove = !IsRoot || allowRemoveRoot;
|
||||||
|
var actuallyRemoved = new List<BaseItem>();
|
||||||
// If it's an AggregateFolder, don't remove
|
// If it's an AggregateFolder, don't remove
|
||||||
if (shouldRemove && itemsRemoved.Count > 0)
|
if (shouldRemove && itemsRemoved.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -467,6 +468,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
{
|
{
|
||||||
Logger.LogDebug("Removed item: {Path}", item.Path);
|
Logger.LogDebug("Removed item: {Path}", item.Path);
|
||||||
|
|
||||||
|
actuallyRemoved.Add(item);
|
||||||
item.SetParent(null);
|
item.SetParent(null);
|
||||||
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
|
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
|
||||||
}
|
}
|
||||||
@@ -477,6 +479,20 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
{
|
{
|
||||||
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After removing items, reattach any detached user data to remaining children
|
||||||
|
// that share the same user data keys (eg. same episode replaced with a new file).
|
||||||
|
if (actuallyRemoved.Count > 0)
|
||||||
|
{
|
||||||
|
var removedKeys = actuallyRemoved.SelectMany(i => i.GetUserDataKeys()).ToHashSet();
|
||||||
|
foreach (var child in validChildren)
|
||||||
|
{
|
||||||
|
if (child.GetUserDataKeys().Any(removedKeys.Contains))
|
||||||
|
{
|
||||||
|
await child.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -201,12 +201,17 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
|
|
||||||
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||||
{
|
{
|
||||||
|
if (series is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
|
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<BaseItem> GetEpisodes()
|
public List<BaseItem> GetEpisodes()
|
||||||
{
|
{
|
||||||
return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
|
return GetEpisodes(Series, null, null, new DtoOptions(true), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
|
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
query.AncestorWithPresentationUniqueKey = null;
|
query.AncestorWithPresentationUniqueKey = null;
|
||||||
query.SeriesPresentationUniqueKey = seriesKey;
|
query.SeriesPresentationUniqueKey = seriesKey;
|
||||||
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
||||||
query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) };
|
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||||
|
|
||||||
if (user is not null && !user.DisplayMissingEpisodes)
|
if (user is not null && !user.DisplayMissingEpisodes)
|
||||||
{
|
{
|
||||||
@@ -247,6 +247,10 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
|
|
||||||
query.AncestorWithPresentationUniqueKey = null;
|
query.AncestorWithPresentationUniqueKey = null;
|
||||||
query.SeriesPresentationUniqueKey = seriesKey;
|
query.SeriesPresentationUniqueKey = seriesKey;
|
||||||
|
if (query.OrderBy.Count == 0)
|
||||||
|
{
|
||||||
|
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||||
|
}
|
||||||
|
|
||||||
if (query.IncludeItemTypes.Length == 0)
|
if (query.IncludeItemTypes.Length == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ public interface IItemRepository
|
|||||||
|
|
||||||
void SaveImages(BaseItem item);
|
void SaveImages(BaseItem item);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reattaches the user data to the item.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
|
||||||
|
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the item.
|
/// Retrieves the item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -350,5 +350,12 @@ namespace MediaBrowser.Controller.Session
|
|||||||
/// <param name="sessionIdOrPlaySessionId">The session id or playsession id.</param>
|
/// <param name="sessionIdOrPlaySessionId">The session id or playsession id.</param>
|
||||||
/// <returns>Task.</returns>
|
/// <returns>Task.</returns>
|
||||||
Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId);
|
Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the dto for session info.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionInfo">The session info.</param>
|
||||||
|
/// <returns><see cref="SessionInfoDto"/> of the session.</returns>
|
||||||
|
SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -693,7 +693,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||||||
[GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
[GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
||||||
private static partial Regex CodecRegex();
|
private static partial Regex CodecRegex();
|
||||||
|
|
||||||
[GeneratedRegex("^\\s\\S{3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
[GeneratedRegex("^\\s\\S{2,3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
||||||
private static partial Regex FilterRegex();
|
private static partial Regex FilterRegex();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ namespace MediaBrowser.Providers.Manager
|
|||||||
|
|
||||||
if (isFirstRefresh)
|
if (isFirstRefresh)
|
||||||
{
|
{
|
||||||
await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, false, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next run metadata providers
|
// Next run metadata providers
|
||||||
@@ -247,7 +247,7 @@ namespace MediaBrowser.Providers.Manager
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false);
|
await SaveItemAsync(metadataResult, updateType, isFirstRefresh, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updateType;
|
return updateType;
|
||||||
@@ -275,9 +275,14 @@ namespace MediaBrowser.Providers.Manager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken)
|
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
|
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (reattachUserData)
|
||||||
|
{
|
||||||
|
await result.Item.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.Item.SupportsPeople && result.People is not null)
|
if (result.Item.SupportsPeople && result.People is not null)
|
||||||
{
|
{
|
||||||
var baseItem = result.Item;
|
var baseItem = result.Item;
|
||||||
|
|||||||
@@ -262,9 +262,28 @@ namespace MediaBrowser.Providers.MediaInfo
|
|||||||
|
|
||||||
private void FetchShortcutInfo(BaseItem item)
|
private void FetchShortcutInfo(BaseItem item)
|
||||||
{
|
{
|
||||||
item.ShortcutPath = File.ReadAllLines(item.Path)
|
var shortcutPath = File.ReadAllLines(item.Path)
|
||||||
.Select(NormalizeStrmLine)
|
.Select(NormalizeStrmLine)
|
||||||
.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#'));
|
.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#'));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(shortcutPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow remote URLs in .strm files to prevent local file access
|
||||||
|
if (Uri.TryCreate(shortcutPath, UriKind.Absolute, out var uri)
|
||||||
|
&& (string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "rtsp", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(uri.Scheme, "rtp", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
item.ShortcutPath = shortcutPath;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Ignoring invalid or non-remote .strm path in {File}: {Path}", item.Path, shortcutPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
|
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replaceData || targetItem.Shares.Count == 0)
|
if (replaceData || targetItem.Shares.Count == 0)
|
||||||
|
|||||||
@@ -303,9 +303,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
|||||||
CrewMember = crewMember,
|
CrewMember = crewMember,
|
||||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||||
})
|
})
|
||||||
.Where(entry =>
|
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
|
||||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (config.HideMissingCrewMembers)
|
if (config.HideMissingCrewMembers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -275,9 +275,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
|||||||
CrewMember = crewMember,
|
CrewMember = crewMember,
|
||||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||||
})
|
})
|
||||||
.Where(entry =>
|
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
|
||||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (config.HideMissingCrewMembers)
|
if (config.HideMissingCrewMembers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
|||||||
CrewMember = crewMember,
|
CrewMember = crewMember,
|
||||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||||
})
|
})
|
||||||
.Where(entry =>
|
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
|
||||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (config.HideMissingCrewMembers)
|
if (config.HideMissingCrewMembers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -367,9 +367,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
|||||||
CrewMember = crewMember,
|
CrewMember = crewMember,
|
||||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||||
})
|
})
|
||||||
.Where(entry =>
|
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
|
||||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (config.HideMissingCrewMembers)
|
if (config.HideMissingCrewMembers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -518,7 +518,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _tmDbClient.GetImageUrl(size, path, true).ToString();
|
// Use "original" as default size if size is null or empty to prevent malformed URLs
|
||||||
|
var imageSize = string.IsNullOrEmpty(size) ? "original" : size;
|
||||||
|
|
||||||
|
return _tmDbClient.GetImageUrl(imageSize, path, true).ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -69,19 +69,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
|||||||
/// <returns>The Jellyfin person type.</returns>
|
/// <returns>The Jellyfin person type.</returns>
|
||||||
public static PersonKind MapCrewToPersonType(Crew crew)
|
public static PersonKind MapCrewToPersonType(Crew crew)
|
||||||
{
|
{
|
||||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
if (crew.Department.Equals("directing", StringComparison.OrdinalIgnoreCase)
|
||||||
&& crew.Job.Contains("director", StringComparison.OrdinalIgnoreCase))
|
&& crew.Job.Equals("director", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return PersonKind.Director;
|
return PersonKind.Director;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
||||||
&& crew.Job.Contains("producer", StringComparison.OrdinalIgnoreCase))
|
&& crew.Job.Equals("producer", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return PersonKind.Producer;
|
return PersonKind.Producer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase))
|
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase) || crew.Job.Equals("screenplay", StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
return PersonKind.Writer;
|
return PersonKind.Writer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Emby.Naming.Common;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -32,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
private readonly ILibraryMonitor _monitor;
|
private readonly ILibraryMonitor _monitor;
|
||||||
private readonly IMediaSourceManager _mediaSourceManager;
|
private readonly IMediaSourceManager _mediaSourceManager;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
|
private readonly HashSet<string> _allowedSubtitleFormats;
|
||||||
|
|
||||||
private readonly ISubtitleProvider[] _subtitleProviders;
|
private readonly ISubtitleProvider[] _subtitleProviders;
|
||||||
|
|
||||||
@@ -41,7 +43,8 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
ILibraryMonitor monitor,
|
ILibraryMonitor monitor,
|
||||||
IMediaSourceManager mediaSourceManager,
|
IMediaSourceManager mediaSourceManager,
|
||||||
ILocalizationManager localizationManager,
|
ILocalizationManager localizationManager,
|
||||||
IEnumerable<ISubtitleProvider> subtitleProviders)
|
IEnumerable<ISubtitleProvider> subtitleProviders,
|
||||||
|
NamingOptions namingOptions)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
@@ -51,6 +54,9 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
_subtitleProviders = subtitleProviders
|
_subtitleProviders = subtitleProviders
|
||||||
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
|
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
_allowedSubtitleFormats = new HashSet<string>(
|
||||||
|
namingOptions.SubtitleFileExtensions.Select(e => e.TrimStart('.')),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -171,6 +177,12 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task UploadSubtitle(Video video, SubtitleResponse response)
|
public Task UploadSubtitle(Video video, SubtitleResponse response)
|
||||||
{
|
{
|
||||||
|
var format = response.Format;
|
||||||
|
if (string.IsNullOrEmpty(format) || !_allowedSubtitleFormats.Contains(format))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Unsupported subtitle format: '{format}'");
|
||||||
|
}
|
||||||
|
|
||||||
var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
|
var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
|
||||||
return TrySaveSubtitle(video, libraryOptions, response);
|
return TrySaveSubtitle(video, libraryOptions, response);
|
||||||
}
|
}
|
||||||
@@ -193,7 +205,13 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
}
|
}
|
||||||
|
|
||||||
var savePaths = new List<string>();
|
var savePaths = new List<string>();
|
||||||
var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant();
|
var language = response.Language.ToLowerInvariant();
|
||||||
|
if (language.AsSpan().IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) >= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Language contains invalid characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + language;
|
||||||
|
|
||||||
if (response.IsForced)
|
if (response.IsForced)
|
||||||
{
|
{
|
||||||
@@ -221,15 +239,22 @@ namespace MediaBrowser.Providers.Subtitles
|
|||||||
|
|
||||||
private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
|
private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
|
||||||
{
|
{
|
||||||
|
if (!_allowedSubtitleFormats.Contains("." + extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid subtitle format: {extension}");
|
||||||
|
}
|
||||||
|
|
||||||
List<Exception>? exs = null;
|
List<Exception>? exs = null;
|
||||||
|
|
||||||
foreach (var savePath in savePaths)
|
foreach (var savePath in savePaths)
|
||||||
{
|
{
|
||||||
var path = savePath + "." + extension;
|
var path = Path.GetFullPath(savePath + "." + extension);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (path.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal)
|
var containingFolder = video.ContainingFolderPath + Path.DirectorySeparatorChar;
|
||||||
|| path.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal))
|
var metadataFolder = video.GetInternalMetadataPath() + Path.DirectorySeparatorChar;
|
||||||
|
if (path.StartsWith(containingFolder, StringComparison.Ordinal)
|
||||||
|
|| path.StartsWith(metadataFolder, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var fileExists = File.Exists(path);
|
var fileExists = File.Exists(path);
|
||||||
var counter = 0;
|
var counter = 0;
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
|
|||||||
writer.WriteElementString("aspectratio", hasAspectRatio.AspectRatio);
|
writer.WriteElementString("aspectratio", hasAspectRatio.AspectRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbCollection))
|
if (item.TryGetProviderId(MetadataProvider.TmdbCollection, out var tmdbCollection))
|
||||||
{
|
{
|
||||||
writer.WriteElementString("collectionnumber", tmdbCollection);
|
writer.WriteElementString("collectionnumber", tmdbCollection);
|
||||||
writtenProviderIds.Add(MetadataProvider.TmdbCollection.ToString());
|
writtenProviderIds.Add(MetadataProvider.TmdbCollection.ToString());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
[assembly: AssemblyVersion("10.11.4")]
|
[assembly: AssemblyVersion("10.11.7")]
|
||||||
[assembly: AssemblyFileVersion("10.11.4")]
|
[assembly: AssemblyFileVersion("10.11.7")]
|
||||||
|
|||||||
@@ -24,61 +24,29 @@ public class SkiaEncoder : IImageEncoder
|
|||||||
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
|
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
|
||||||
private readonly ILogger<SkiaEncoder> _logger;
|
private readonly ILogger<SkiaEncoder> _logger;
|
||||||
private readonly IApplicationPaths _appPaths;
|
private readonly IApplicationPaths _appPaths;
|
||||||
private static readonly SKImageFilter _imageFilter;
|
private static readonly SKTypeface?[] _typefaces = InitializeTypefaces();
|
||||||
private static readonly SKTypeface[] _typefaces;
|
private static readonly SKImageFilter _imageFilter = SKImageFilter.CreateMatrixConvolution(
|
||||||
|
new SKSizeI(3, 3),
|
||||||
|
[
|
||||||
|
0, -.1f, 0,
|
||||||
|
-.1f, 1.4f, -.1f,
|
||||||
|
0, -.1f, 0
|
||||||
|
],
|
||||||
|
1f,
|
||||||
|
0f,
|
||||||
|
new SKPointI(1, 1),
|
||||||
|
SKShaderTileMode.Clamp,
|
||||||
|
true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The default sampling options, equivalent to old high quality filter settings when upscaling.
|
/// The default sampling options, equivalent to old high quality filter settings when upscaling.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly SKSamplingOptions UpscaleSamplingOptions;
|
public static readonly SKSamplingOptions UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upscaling.
|
/// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upscaling.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly SKSamplingOptions DefaultSamplingOptions;
|
public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
|
||||||
|
|
||||||
#pragma warning disable CA1810
|
|
||||||
static SkiaEncoder()
|
|
||||||
#pragma warning restore CA1810
|
|
||||||
{
|
|
||||||
var kernel = new[]
|
|
||||||
{
|
|
||||||
0, -.1f, 0,
|
|
||||||
-.1f, 1.4f, -.1f,
|
|
||||||
0, -.1f, 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
var kernelSize = new SKSizeI(3, 3);
|
|
||||||
var kernelOffset = new SKPointI(1, 1);
|
|
||||||
_imageFilter = SKImageFilter.CreateMatrixConvolution(
|
|
||||||
kernelSize,
|
|
||||||
kernel,
|
|
||||||
1f,
|
|
||||||
0f,
|
|
||||||
kernelOffset,
|
|
||||||
SKShaderTileMode.Clamp,
|
|
||||||
true);
|
|
||||||
|
|
||||||
// Initialize the list of typefaces
|
|
||||||
// We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code point
|
|
||||||
// But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻♀️ is a single emoji but 5 code points (U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F)
|
|
||||||
_typefaces =
|
|
||||||
[
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '鸡'), // CJK Simplified Chinese
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '雞'), // CJK Traditional Chinese
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ノ'), // CJK Japanese
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '각'), // CJK Korean
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 128169), // Emojis, 128169 is the 💩emoji
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ז'), // Hebrew
|
|
||||||
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ي'), // Arabic
|
|
||||||
SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) // Default font
|
|
||||||
];
|
|
||||||
|
|
||||||
// use cubic for upscaling
|
|
||||||
UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell);
|
|
||||||
// use bilinear for everything else
|
|
||||||
DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
|
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
|
||||||
@@ -132,7 +100,7 @@ public class SkiaEncoder : IImageEncoder
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the default typeface to use.
|
/// Gets the default typeface to use.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static SKTypeface DefaultTypeFace => _typefaces.Last();
|
public static SKTypeface? DefaultTypeFace => _typefaces.Last();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Check if the native lib is available.
|
/// Check if the native lib is available.
|
||||||
@@ -152,6 +120,40 @@ public class SkiaEncoder : IImageEncoder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize the list of typefaces
|
||||||
|
/// We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code point
|
||||||
|
/// But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻♀️ is a single emoji but 5 code points (U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The list of typefaces.</returns>
|
||||||
|
private static SKTypeface?[] InitializeTypefaces()
|
||||||
|
{
|
||||||
|
int[] chars = [
|
||||||
|
'鸡', // CJK Simplified Chinese
|
||||||
|
'雞', // CJK Traditional Chinese
|
||||||
|
'ノ', // CJK Japanese
|
||||||
|
'각', // CJK Korean
|
||||||
|
128169, // Emojis, 128169 is the Pile of Poo (💩) emoji
|
||||||
|
'ז', // Hebrew
|
||||||
|
'ي' // Arabic
|
||||||
|
];
|
||||||
|
var fonts = new List<SKTypeface>(chars.Length + 1);
|
||||||
|
foreach (var ch in chars)
|
||||||
|
{
|
||||||
|
var font = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, ch);
|
||||||
|
if (font is not null)
|
||||||
|
{
|
||||||
|
fonts.Add(font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default font
|
||||||
|
fonts.Add(SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright)
|
||||||
|
?? SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'a'));
|
||||||
|
|
||||||
|
return fonts.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
|
/// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -809,7 +811,7 @@ public class SkiaEncoder : IImageEncoder
|
|||||||
{
|
{
|
||||||
foreach (var typeface in _typefaces)
|
foreach (var typeface in _typefaces)
|
||||||
{
|
{
|
||||||
if (typeface.ContainsGlyphs(c))
|
if (typeface is not null && typeface.ContainsGlyphs(c))
|
||||||
{
|
{
|
||||||
return typeface;
|
return typeface;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Extensions</PackageId>
|
<PackageId>Jellyfin.Extensions</PackageId>
|
||||||
<VersionPrefix>10.11.4</VersionPrefix>
|
<VersionPrefix>10.11.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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -195,6 +195,18 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
|
|||||||
Assert.False(res.MediaStreams[0].IsAVC);
|
Assert.False(res.MediaStreams[0].IsAVC);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetMediaInfo_WebM_Like_Mkv()
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes("Test Data/Probing/video_web_like_mkv_with_subtitle.json");
|
||||||
|
var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
|
||||||
|
|
||||||
|
MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File);
|
||||||
|
|
||||||
|
Assert.Equal("mkv", res.Container);
|
||||||
|
Assert.Equal(3, res.MediaStreams.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success()
|
public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"codec_name": "vp8",
|
||||||
|
"codec_long_name": "On2 VP8",
|
||||||
|
"profile": "1",
|
||||||
|
"codec_type": "video",
|
||||||
|
"codec_tag_string": "[0][0][0][0]",
|
||||||
|
"codec_tag": "0x0000",
|
||||||
|
"width": 540,
|
||||||
|
"height": 360,
|
||||||
|
"coded_width": 540,
|
||||||
|
"coded_height": 360,
|
||||||
|
"closed_captions": 0,
|
||||||
|
"film_grain": 0,
|
||||||
|
"has_b_frames": 0,
|
||||||
|
"sample_aspect_ratio": "1:1",
|
||||||
|
"display_aspect_ratio": "3:2",
|
||||||
|
"pix_fmt": "yuv420p",
|
||||||
|
"level": -99,
|
||||||
|
"field_order": "progressive",
|
||||||
|
"refs": 1,
|
||||||
|
"r_frame_rate": "2997/125",
|
||||||
|
"avg_frame_rate": "2997/125",
|
||||||
|
"time_base": "1/1000",
|
||||||
|
"start_pts": 0,
|
||||||
|
"start_time": "0.000000",
|
||||||
|
"disposition": {
|
||||||
|
"default": 1,
|
||||||
|
"dub": 0,
|
||||||
|
"original": 0,
|
||||||
|
"comment": 0,
|
||||||
|
"lyrics": 0,
|
||||||
|
"karaoke": 0,
|
||||||
|
"forced": 0,
|
||||||
|
"hearing_impaired": 0,
|
||||||
|
"visual_impaired": 0,
|
||||||
|
"clean_effects": 0,
|
||||||
|
"attached_pic": 0,
|
||||||
|
"timed_thumbnails": 0,
|
||||||
|
"captions": 0,
|
||||||
|
"descriptions": 0,
|
||||||
|
"metadata": 0,
|
||||||
|
"dependent": 0,
|
||||||
|
"still_image": 0
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"language": "eng"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"codec_name": "vorbis",
|
||||||
|
"codec_long_name": "Vorbis",
|
||||||
|
"codec_type": "audio",
|
||||||
|
"codec_tag_string": "[0][0][0][0]",
|
||||||
|
"codec_tag": "0x0000",
|
||||||
|
"sample_fmt": "fltp",
|
||||||
|
"sample_rate": "44100",
|
||||||
|
"channels": 2,
|
||||||
|
"channel_layout": "stereo",
|
||||||
|
"bits_per_sample": 0,
|
||||||
|
"r_frame_rate": "0/0",
|
||||||
|
"avg_frame_rate": "0/0",
|
||||||
|
"time_base": "1/1000",
|
||||||
|
"start_pts": 0,
|
||||||
|
"start_time": "0.000000",
|
||||||
|
"duration": "117.707000",
|
||||||
|
"bit_rate": "127998",
|
||||||
|
"disposition": {
|
||||||
|
"default": 1,
|
||||||
|
"dub": 0,
|
||||||
|
"original": 0,
|
||||||
|
"comment": 0,
|
||||||
|
"lyrics": 0,
|
||||||
|
"karaoke": 0,
|
||||||
|
"forced": 0,
|
||||||
|
"hearing_impaired": 0,
|
||||||
|
"visual_impaired": 0,
|
||||||
|
"clean_effects": 0,
|
||||||
|
"attached_pic": 0,
|
||||||
|
"timed_thumbnails": 0,
|
||||||
|
"captions": 0,
|
||||||
|
"descriptions": 0,
|
||||||
|
"metadata": 0,
|
||||||
|
"dependent": 0,
|
||||||
|
"still_image": 0
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"language": "eng"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"codec_name": "subrip",
|
||||||
|
"codec_long_name": "SubRip subtitle",
|
||||||
|
"codec_type": "subtitle",
|
||||||
|
"codec_tag_string": "[0][0][0][0]",
|
||||||
|
"codec_tag": "0x0000",
|
||||||
|
"disposition": {
|
||||||
|
"default": 0,
|
||||||
|
"dub": 0,
|
||||||
|
"original": 0,
|
||||||
|
"comment": 0,
|
||||||
|
"lyrics": 0,
|
||||||
|
"karaoke": 0,
|
||||||
|
"forced": 0,
|
||||||
|
"hearing_impaired": 0,
|
||||||
|
"visual_impaired": 0,
|
||||||
|
"clean_effects": 0,
|
||||||
|
"attached_pic": 0,
|
||||||
|
"timed_thumbnails": 0,
|
||||||
|
"captions": 0,
|
||||||
|
"descriptions": 0,
|
||||||
|
"metadata": 0,
|
||||||
|
"dependent": 0,
|
||||||
|
"still_image": 0
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"language": "eng"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"format": {
|
||||||
|
"filename": "sample.mkv",
|
||||||
|
"nb_streams": 3,
|
||||||
|
"nb_programs": 0,
|
||||||
|
"format_name": "matroska,webm",
|
||||||
|
"format_long_name": "Matroska / WebM",
|
||||||
|
"start_time": "0.000000",
|
||||||
|
"duration": "117.700914",
|
||||||
|
"size": "8566268",
|
||||||
|
"bit_rate": "582239",
|
||||||
|
"probe_score": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,25 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
|
|||||||
Assert.Null(localizationManager.GetRatingScore(value));
|
Assert.Null(localizationManager.GetRatingScore(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("TV-MA", "DE", 17, 1)] // US-only rating, DE country code
|
||||||
|
[InlineData("PG-13", "FR", 13, 0)] // US-only rating, FR country code
|
||||||
|
[InlineData("R", "JP", 17, 0)] // US-only rating, JP country code
|
||||||
|
public async Task GetRatingScore_FallbackPrioritizesUS_Success(string rating, string countryCode, int expectedScore, int? expectedSubScore)
|
||||||
|
{
|
||||||
|
var localizationManager = Setup(new ServerConfiguration()
|
||||||
|
{
|
||||||
|
MetadataCountryCode = countryCode
|
||||||
|
});
|
||||||
|
await localizationManager.LoadAll();
|
||||||
|
|
||||||
|
var score = localizationManager.GetRatingScore(rating);
|
||||||
|
|
||||||
|
Assert.NotNull(score);
|
||||||
|
Assert.Equal(expectedScore, score.Score);
|
||||||
|
Assert.Equal(expectedSubScore, score.SubScore);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Default", "Default")]
|
[InlineData("Default", "Default")]
|
||||||
[InlineData("HeaderLiveTV", "Live TV")]
|
[InlineData("HeaderLiveTV", "Live TV")]
|
||||||
|
|||||||
Reference in New Issue
Block a user