diff options
53 files changed, 663 insertions, 522 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 39fe6f1d2..d179ff4f6 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@4355270be187e1b672a7a1c7c7bae5afdc1ab94a # v3.24.10 + uses: github/codeql-action/init@c7f9125735019aa87cfc361530512d50ea439c71 # v3.25.1 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@4355270be187e1b672a7a1c7c7bae5afdc1ab94a # v3.24.10 + uses: github/codeql-action/autobuild@c7f9125735019aa87cfc361530512d50ea439c71 # v3.25.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4355270be187e1b672a7a1c7c7bae5afdc1ab94a # v3.24.10 + uses: github/codeql-action/analyze@c7f9125735019aa87cfc361530512d50ea439c71 # v3.25.1 diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml index 05517bb03..1c3fac3c6 100644 --- a/.github/workflows/pull-request-conflict.yml +++ b/.github/workflows/pull-request-conflict.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Apply label - uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0 + uses: eps1lon/actions-label-merge-conflict@e62d7a53ff8be8b97684bffb6cfbbf3fc1115e2e # v3.0.0 if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: dirtyLabel: 'merge conflict' diff --git a/Directory.Packages.props b/Directory.Packages.props index d67dba225..e125b536a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,13 +16,13 @@ <PackageVersion Include="Diacritics" Version="3.3.27" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> - <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.3.1" /> + <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.4.1" /> <PackageVersion Include="FsCheck.Xunit" Version="2.16.6" /> <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.2" /> <PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" /> <PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" /> <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" /> - <PackageVersion Include="libse" Version="3.6.13" /> + <PackageVersion Include="libse" Version="4.0.5" /> <PackageVersion Include="LrcParser" Version="2023.524.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.4" /> diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index f83da566b..34dc027f1 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -101,14 +101,14 @@ namespace Emby.Server.Implementations.HttpServer var pipe = new Pipe(); var writer = pipe.Writer; - ValueWebSocketReceiveResult receiveresult; + ValueWebSocketReceiveResult receiveResult; do { // Allocate at least 512 bytes from the PipeWriter Memory<byte> memory = writer.GetMemory(512); try { - receiveresult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); + receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); } catch (WebSocketException ex) { @@ -116,7 +116,7 @@ namespace Emby.Server.Implementations.HttpServer break; } - int bytesRead = receiveresult.Count; + int bytesRead = receiveResult.Count; if (bytesRead == 0) { break; @@ -135,13 +135,13 @@ namespace Emby.Server.Implementations.HttpServer LastActivityDate = DateTime.UtcNow; - if (receiveresult.EndOfMessage) + if (receiveResult.EndOfMessage) { await ProcessInternal(pipe.Reader).ConfigureAwait(false); } } while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting) - && receiveresult.MessageType != WebSocketMessageType.Close); + && receiveResult.MessageType != WebSocketMessageType.Close); Closed?.Invoke(this, EventArgs.Empty); diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs index 665d70a41..b01fd93a7 100644 --- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs @@ -29,7 +29,7 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> - public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) + public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) { // Don't ignore application folders if (fileInfo.FullName.Contains(_serverApplicationPaths.RootFolderPath, StringComparison.InvariantCulture)) diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index ea8b83125..7b3871a9b 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -46,6 +44,7 @@ using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; +using TMDbLib.Objects.Authentication; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using EpisodeInfo = Emby.Naming.TV.EpisodeInfo; using Genre = MediaBrowser.Controller.Entities.Genre; @@ -89,8 +88,8 @@ namespace Emby.Server.Implementations.Library /// <summary> /// The _root folder. /// </summary> - private volatile AggregateFolder _rootFolder; - private volatile UserRootFolder _userRootFolder; + private volatile AggregateFolder? _rootFolder; + private volatile UserRootFolder? _userRootFolder; private bool _wizardCompleted; @@ -155,17 +154,17 @@ namespace Emby.Server.Implementations.Library /// <summary> /// Occurs when [item added]. /// </summary> - public event EventHandler<ItemChangeEventArgs> ItemAdded; + public event EventHandler<ItemChangeEventArgs>? ItemAdded; /// <summary> /// Occurs when [item updated]. /// </summary> - public event EventHandler<ItemChangeEventArgs> ItemUpdated; + public event EventHandler<ItemChangeEventArgs>? ItemUpdated; /// <summary> /// Occurs when [item removed]. /// </summary> - public event EventHandler<ItemChangeEventArgs> ItemRemoved; + public event EventHandler<ItemChangeEventArgs>? ItemRemoved; /// <summary> /// Gets the root folder. @@ -264,7 +263,7 @@ namespace Emby.Server.Implementations.Library /// </summary> /// <param name="sender">The sender.</param> /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param> - private void ConfigurationUpdated(object sender, EventArgs e) + private void ConfigurationUpdated(object? sender, EventArgs e) { var config = _configurationManager.Configuration; @@ -479,7 +478,7 @@ namespace Emby.Server.Implementations.Library /// <param name="args">The args.</param> /// <param name="resolvers">The resolvers.</param> /// <returns>BaseItem.</returns> - private BaseItem ResolveItem(ItemResolveArgs args, IItemResolver[] resolvers) + private BaseItem? ResolveItem(ItemResolveArgs args, IItemResolver[]? resolvers) { var item = (resolvers ?? EntityResolvers).Select(r => Resolve(args, r)) .FirstOrDefault(i => i is not null); @@ -492,7 +491,7 @@ namespace Emby.Server.Implementations.Library return item; } - private BaseItem Resolve(ItemResolveArgs args, IItemResolver resolver) + private BaseItem? Resolve(ItemResolveArgs args, IItemResolver resolver) { try { @@ -534,16 +533,16 @@ namespace Emby.Server.Implementations.Library return key.GetMD5(); } - public BaseItem ResolvePath(FileSystemMetadata fileInfo, Folder parent = null, IDirectoryService directoryService = null) + public BaseItem? ResolvePath(FileSystemMetadata fileInfo, Folder? parent = null, IDirectoryService? directoryService = null) => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent); - private BaseItem ResolvePath( + private BaseItem? ResolvePath( FileSystemMetadata fileInfo, IDirectoryService directoryService, - IItemResolver[] resolvers, - Folder parent = null, + IItemResolver[]? resolvers, + Folder? parent = null, CollectionType? collectionType = null, - LibraryOptions libraryOptions = null) + LibraryOptions? libraryOptions = null) { ArgumentNullException.ThrowIfNull(fileInfo); @@ -616,7 +615,7 @@ namespace Emby.Server.Implementations.Library return ResolveItem(args, resolvers); } - public bool IgnoreFile(FileSystemMetadata file, BaseItem parent) + public bool IgnoreFile(FileSystemMetadata file, BaseItem? parent) => EntityResolutionIgnoreRules.Any(r => r.ShouldIgnore(file, parent)); public List<FileSystemMetadata> NormalizeRootPathList(IEnumerable<FileSystemMetadata> paths) @@ -691,16 +690,16 @@ namespace Emby.Server.Implementations.Library private IEnumerable<BaseItem> ResolveFileList( IReadOnlyList<FileSystemMetadata> fileList, IDirectoryService directoryService, - Folder parent, + Folder? parent, CollectionType? collectionType, - IItemResolver[] resolvers, + IItemResolver[]? resolvers, LibraryOptions libraryOptions) { // Given that fileList is a list we can save enumerator allocations by indexing for (var i = 0; i < fileList.Count; i++) { var file = fileList[i]; - BaseItem result = null; + BaseItem? result = null; try { result = ResolvePath(file, directoryService, resolvers, parent, collectionType, libraryOptions); @@ -729,7 +728,7 @@ namespace Emby.Server.Implementations.Library Directory.CreateDirectory(rootFolderPath); var rootFolder = GetItemById(GetNewItemId(rootFolderPath, typeof(AggregateFolder))) as AggregateFolder ?? - ((Folder)ResolvePath(_fileSystem.GetDirectoryInfo(rootFolderPath))) + (ResolvePath(_fileSystem.GetDirectoryInfo(rootFolderPath)) as Folder ?? throw new InvalidOperationException("Something went very wong")) .DeepCopy<Folder, AggregateFolder>(); // In case program data folder was moved @@ -795,7 +794,7 @@ namespace Emby.Server.Implementations.Library Directory.CreateDirectory(userRootPath); var newItemId = GetNewItemId(userRootPath, typeof(UserRootFolder)); - UserRootFolder tmpItem = null; + UserRootFolder? tmpItem = null; try { tmpItem = GetItemById(newItemId) as UserRootFolder; @@ -808,7 +807,8 @@ namespace Emby.Server.Implementations.Library if (tmpItem is null) { _logger.LogDebug("Creating new userRootFolder with DeepCopy"); - tmpItem = ((Folder)ResolvePath(_fileSystem.GetDirectoryInfo(userRootPath))).DeepCopy<Folder, UserRootFolder>(); + tmpItem = (ResolvePath(_fileSystem.GetDirectoryInfo(userRootPath)) as Folder ?? throw new InvalidOperationException("Failed to get user root path")) + .DeepCopy<Folder, UserRootFolder>(); } // In case program data folder was moved @@ -827,7 +827,8 @@ namespace Emby.Server.Implementations.Library return _userRootFolder; } - public BaseItem FindByPath(string path, bool? isFolder) + /// <inheritdoc /> + public BaseItem? FindByPath(string path, bool? isFolder) { // If this returns multiple items it could be tricky figuring out which one is correct. // In most cases, the newest one will be and the others obsolete but not yet cleaned up @@ -846,12 +847,8 @@ namespace Emby.Server.Implementations.Library .FirstOrDefault(); } - /// <summary> - /// Gets the person. - /// </summary> - /// <param name="name">The name.</param> - /// <returns>Task{Person}.</returns> - public Person GetPerson(string name) + /// <inheritdoc /> + public Person? GetPerson(string name) { var path = Person.GetPath(name); var id = GetItemByNameId<Person>(path); @@ -1159,7 +1156,7 @@ namespace Emby.Server.Implementations.Library .ToList(); } - private VirtualFolderInfo GetVirtualFolderInfo(string dir, List<BaseItem> allCollectionFolders, HashSet<Guid> refreshQueue) + private VirtualFolderInfo GetVirtualFolderInfo(string dir, List<BaseItem> allCollectionFolders, HashSet<Guid>? refreshQueue) { var info = new VirtualFolderInfo { @@ -1223,20 +1220,15 @@ namespace Emby.Server.Implementations.Library return null; } - /// <summary> - /// Gets the item by id. - /// </summary> - /// <param name="id">The id.</param> - /// <returns>BaseItem.</returns> - /// <exception cref="ArgumentNullException"><paramref name="id"/> is <c>null</c>.</exception> - public BaseItem GetItemById(Guid id) + /// <inheritdoc /> + public BaseItem? GetItemById(Guid id) { if (id.IsEmpty()) { throw new ArgumentException("Guid can't be empty", nameof(id)); } - if (_cache.TryGetValue(id, out BaseItem item)) + if (_cache.TryGetValue(id, out BaseItem? item)) { return item; } @@ -1252,7 +1244,7 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> - public T GetItemById<T>(Guid id) + public T? GetItemById<T>(Guid id) where T : BaseItem { var item = GetItemById(id); @@ -1264,6 +1256,22 @@ namespace Emby.Server.Implementations.Library return null; } + /// <inheritdoc /> + public T? GetItemById<T>(Guid id, Guid userId) + where T : BaseItem + { + var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId); + return GetItemById<T>(id, user); + } + + /// <inheritdoc /> + public T? GetItemById<T>(Guid id, User? user) + where T : BaseItem + { + var item = GetItemById<T>(id); + return ItemIsVisible(item, user) ? item : null; + } + public List<BaseItem> GetItemList(InternalItemsQuery query, bool allowExternalContent) { if (query.Recursive && !query.ParentId.IsEmpty()) @@ -1424,7 +1432,7 @@ namespace Emby.Server.Implementations.Library var parents = new BaseItem[len]; for (int i = 0; i < len; i++) { - parents[i] = GetItemById(ancestorIds[i]); + parents[i] = GetItemById(ancestorIds[i]) ?? throw new ArgumentException($"Failed to find parent with id: {ancestorIds[i]}"); if (parents[i] is not (ICollectionFolder or UserView)) { return; @@ -1438,7 +1446,7 @@ namespace Emby.Server.Implementations.Library // Prevent searching in all libraries due to empty filter if (query.TopParentIds.Length == 0) { - query.TopParentIds = new[] { Guid.NewGuid() }; + query.TopParentIds = [Guid.NewGuid()]; } } @@ -1535,7 +1543,7 @@ namespace Emby.Server.Implementations.Library } } - private IEnumerable<Guid> GetTopParentIdsForQuery(BaseItem item, User user) + private IEnumerable<Guid> GetTopParentIdsForQuery(BaseItem item, User? user) { if (item is UserView view) { @@ -1613,7 +1621,7 @@ namespace Emby.Server.Implementations.Library return items .SelectMany(i => i.ToArray()) .Select(ResolveIntro) - .Where(i => i is not null); + .Where(i => i is not null)!; // null values got filtered out } /// <summary> @@ -1642,9 +1650,9 @@ namespace Emby.Server.Implementations.Library /// </summary> /// <param name="info">The info.</param> /// <returns>Video.</returns> - private Video ResolveIntro(IntroInfo info) + private Video? ResolveIntro(IntroInfo info) { - Video video = null; + Video? video = null; if (info.ItemId.HasValue) { @@ -1695,29 +1703,26 @@ namespace Emby.Server.Implementations.Library return video; } - /// <summary> - /// Sorts the specified sort by. - /// </summary> - /// <param name="items">The items.</param> - /// <param name="user">The user.</param> - /// <param name="sortBy">The sort by.</param> - /// <param name="sortOrder">The sort order.</param> - /// <returns>IEnumerable{BaseItem}.</returns> - public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder) + /// <inheritdoc /> + public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder) { var isFirst = true; - IOrderedEnumerable<BaseItem> orderedItems = null; + IOrderedEnumerable<BaseItem>? orderedItems = null; foreach (var orderBy in sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null)) { if (isFirst) { - orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, orderBy) : items.OrderBy(i => i, orderBy); + orderedItems = sortOrder == SortOrder.Descending + ? items.OrderByDescending(i => i, orderBy) + : items.OrderBy(i => i, orderBy); } else { - orderedItems = sortOrder == SortOrder.Descending ? orderedItems.ThenByDescending(i => i, orderBy) : orderedItems.ThenBy(i => i, orderBy); + orderedItems = sortOrder == SortOrder.Descending + ? orderedItems!.ThenByDescending(i => i, orderBy) + : orderedItems!.ThenBy(i => i, orderBy); // orderedItems is set during the first iteration } isFirst = false; @@ -1726,11 +1731,12 @@ namespace Emby.Server.Implementations.Library return orderedItems ?? items; } - public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy) + /// <inheritdoc /> + public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy) { var isFirst = true; - IOrderedEnumerable<BaseItem> orderedItems = null; + IOrderedEnumerable<BaseItem>? orderedItems = null; foreach (var (name, sortOrder) in orderBy) { @@ -1742,11 +1748,15 @@ namespace Emby.Server.Implementations.Library if (isFirst) { - orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, comparer) : items.OrderBy(i => i, comparer); + orderedItems = sortOrder == SortOrder.Descending + ? items.OrderByDescending(i => i, comparer) + : items.OrderBy(i => i, comparer); } else { - orderedItems = sortOrder == SortOrder.Descending ? orderedItems.ThenByDescending(i => i, comparer) : orderedItems.ThenBy(i => i, comparer); + orderedItems = sortOrder == SortOrder.Descending + ? orderedItems!.ThenByDescending(i => i, comparer) + : orderedItems!.ThenBy(i => i, comparer); // orderedItems is set during the first iteration } isFirst = false; @@ -1761,14 +1771,14 @@ namespace Emby.Server.Implementations.Library /// <param name="name">The name.</param> /// <param name="user">The user.</param> /// <returns>IBaseItemComparer.</returns> - private IBaseItemComparer GetComparer(ItemSortBy name, User user) + private IBaseItemComparer? GetComparer(ItemSortBy name, User? user) { var comparer = Comparers.FirstOrDefault(c => name == c.Type); // If it requires a user, create a new one, and assign the user if (comparer is IUserBaseItemComparer) { - var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType()); + var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances userComparer.User = user; userComparer.UserManager = _userManager; @@ -1780,23 +1790,14 @@ namespace Emby.Server.Implementations.Library return comparer; } - /// <summary> - /// Creates the item. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="parent">The parent item.</param> - public void CreateItem(BaseItem item, BaseItem parent) + /// <inheritdoc /> + public void CreateItem(BaseItem item, BaseItem? parent) { CreateItems(new[] { item }, parent, CancellationToken.None); } - /// <summary> - /// Creates the items. - /// </summary> - /// <param name="items">The items.</param> - /// <param name="parent">The parent item.</param> - /// <param name="cancellationToken">The cancellation token.</param> - public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem parent, CancellationToken cancellationToken) + /// <inheritdoc /> + public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken) { _itemRepository.SaveItems(items, cancellationToken); @@ -2078,16 +2079,16 @@ namespace Emby.Server.Implementations.Library public LibraryOptions GetLibraryOptions(BaseItem item) { - if (item is not CollectionFolder collectionFolder) + if (item is CollectionFolder collectionFolder) { - // List.Find is more performant than FirstOrDefault due to enumerator allocation - collectionFolder = GetCollectionFolders(item) - .Find(folder => folder is CollectionFolder) as CollectionFolder; + return collectionFolder.GetLibraryOptions(); } - return collectionFolder is null - ? new LibraryOptions() - : collectionFolder.GetLibraryOptions(); + // List.Find is more performant than FirstOrDefault due to enumerator allocation + return GetCollectionFolders(item) + .Find(folder => folder is CollectionFolder) is CollectionFolder collectionFolder2 + ? collectionFolder2.GetLibraryOptions() + : new LibraryOptions(); } public CollectionType? GetContentType(BaseItem item) @@ -2441,7 +2442,7 @@ namespace Emby.Server.Implementations.Library { if (parentId.HasValue) { - return GetItemById(parentId.Value); + return GetItemById(parentId.Value) ?? throw new ArgumentException($"Invalid parent id: {parentId.Value}"); } if (!userId.IsNullOrEmpty()) @@ -2478,7 +2479,7 @@ namespace Emby.Server.Implementations.Library var isFolder = episode.VideoType == VideoType.BluRay || episode.VideoType == VideoType.Dvd; // TODO nullable - what are we trying to do there with empty episodeInfo? - EpisodeInfo episodeInfo = null; + EpisodeInfo? episodeInfo = null; if (episode.IsFileProtocol) { episodeInfo = resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming); @@ -2681,7 +2682,7 @@ namespace Emby.Server.Implementations.Library } } - BaseItem GetExtra(FileSystemMetadata file, ExtraType extraType) + BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType) { var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType)); if (extra is not Video && extra is not Audio) @@ -2708,9 +2709,9 @@ namespace Emby.Server.Implementations.Library } } - public string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem) + public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem) { - string newPath; + string? newPath; if (ownerItem is not null) { var libraryOptions = GetLibraryOptions(ownerItem); @@ -2784,8 +2785,8 @@ namespace Emby.Server.Implementations.Library } }) .Where(i => i is not null) - .Where(i => query.User is null || i.IsVisible(query.User)) - .ToList(); + .Where(i => query.User is null || i!.IsVisible(query.User)) + .ToList()!; // null values are filtered out } public List<string> GetPeopleNames(InternalPeopleQuery query) @@ -2887,7 +2888,7 @@ namespace Emby.Server.Implementations.Library if (collectionType is not null) { - var path = Path.Combine(virtualFolderPath, collectionType.ToString().ToLowerInvariant() + ".collection"); + var path = Path.Combine(virtualFolderPath, collectionType.ToString()!.ToLowerInvariant() + ".collection"); // Can't be null with legal values? await File.WriteAllBytesAsync(path, Array.Empty<byte>()).ConfigureAwait(false); } @@ -2921,7 +2922,7 @@ namespace Emby.Server.Implementations.Library private async Task SavePeopleMetadataAsync(IEnumerable<PersonInfo> people, CancellationToken cancellationToken) { - List<BaseItem> personsToSave = null; + List<BaseItem>? personsToSave = null; foreach (var person in people) { @@ -3139,7 +3140,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentNullException(nameof(path)); } - List<NameValuePair> removeList = null; + List<NameValuePair>? removeList = null; foreach (var contentType in _configurationManager.Configuration.ContentTypes) { @@ -3192,5 +3193,20 @@ namespace Emby.Server.Implementations.Library CollectionFolder.SaveLibraryOptions(virtualFolderPath, libraryOptions); } + + private static bool ItemIsVisible(BaseItem? item, User? user) + { + if (item is null) + { + return false; + } + + if (user is null) + { + return true; + } + + return item is UserRootFolder || item.IsVisibleStandalone(user); + } } } diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index c4b6b3756..21e7079d8 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -31,8 +31,9 @@ namespace Emby.Server.Implementations.Library var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); - // Must be at least 3 characters after the attribute =, ], any character. - var maxIndex = str.Length - attribute.Length - 3; + // Must be at least 3 characters after the attribute =, ], any character, + // then we offset it by 1, because we want the index and not length. + var maxIndex = str.Length - attribute.Length - 2; while (attributeIndex > -1 && attributeIndex < maxIndex) { var attributeEnd = attributeIndex + attribute.Length; diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index 601aab5b9..725b8f76c 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -64,6 +64,11 @@ namespace Emby.Server.Implementations.Library.Validators try { var item = _libraryManager.GetPerson(person); + if (item is null) + { + _logger.LogWarning("Failed to get person: {Name}", person); + continue; + } var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { @@ -92,7 +97,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { BaseItemKind.Person }, + IncludeItemTypes = [BaseItemKind.Person], IsDeadPerson = true, IsLocked = false }); diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 8364ce236..ce5177d1f 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -126,5 +126,7 @@ "External": "خارجی", "HearingImpaired": "مشکل شنوایی", "TaskRefreshTrickplayImages": "تولید تصاویر Trickplay", - "TaskRefreshTrickplayImagesDescription": "تولید پیشنمایش های trickplay برای ویدیو های فعال شده در کتابخانه." + "TaskRefreshTrickplayImagesDescription": "تولید پیشنمایش های trickplay برای ویدیو های فعال شده در کتابخانه.", + "TaskCleanCollectionsAndPlaylists": "پاکسازی مجموعه ها و لیست پخش", + "TaskCleanCollectionsAndPlaylistsDescription": "موارد را از مجموعه ها و لیست پخش هایی که دیگر وجود ندارند حذف میکند." } diff --git a/Emby.Server.Implementations/Localization/Core/my.json b/Emby.Server.Implementations/Localization/Core/my.json index 198f7540c..4cb4cdc75 100644 --- a/Emby.Server.Implementations/Localization/Core/my.json +++ b/Emby.Server.Implementations/Localization/Core/my.json @@ -48,7 +48,7 @@ "Undefined": "သတ်မှတ်မထားသော", "TvShows": "တီဗီ ဇာတ်လမ်းတွဲများ", "System": "စနစ်", - "Sync": "ထပ်တူကျသည်။", + "Sync": "ချိန်ကိုက်မည်", "SubtitleDownloadFailureFromForItem": "{1} အတွက် {0} မှ စာတန်းထိုးများ ဒေါင်းလုဒ်လုပ်ခြင်း မအောင်မြင်ပါ", "StartupEmbyServerIsLoading": "Jellyfin ဆာဗာကို အသင့်ပြင်နေပါသည်။ ခဏနေ ထပ်စမ်းကြည့်ပါ။", "Songs": "သီချင်းများ", @@ -104,7 +104,7 @@ "HeaderFavoriteSongs": "အကြိုက်ဆုံးသီချင်းများ", "HeaderFavoriteShows": "အကြိုက်ဆုံး ဇာတ်လမ်းတွဲများ", "HeaderFavoriteEpisodes": "အကြိုက်ဆုံး ဇာတ်လမ်းအပိုင်းများ", - "HeaderFavoriteArtists": "အကြိုက်ဆုံးအနုပညာရှင်များ", + "HeaderFavoriteArtists": "အကြိုက်ဆုံး အနုပညာရှင်များ", "HeaderFavoriteAlbums": "အကြိုက်ဆုံး အယ်လ်ဘမ်များ", "HeaderContinueWatching": "ဆက်လက်ကြည့်ရှုပါ", "HeaderAlbumArtists": "အယ်လ်ဘမ်အနုပညာရှင်များ", @@ -120,5 +120,11 @@ "AuthenticationSucceededWithUserName": "{0} အောင်မြင်စွာ စစ်မှန်ကြောင်း အတည်ပြုပြီးပါပြီ", "Application": "အပလီကေးရှင်း", "AppDeviceValues": "အက်ပ်- {0}၊ စက်- {1}", - "External": "ပြင်ပ" + "External": "ပြင်ပ", + "TaskKeyframeExtractorDescription": "ပိုမိုတိကျသည့် အိတ်ချ်အယ်လ်အက်စ် အစဉ်လိုက်ပြသမှုများ ဖန်တီးနိုင်ရန်အတွက် ဗီဒီယိုဖိုင်များမှ ကီးဖရိန်များကို ထုတ်နှုတ်ယူမည် ဖြစ်သည်။ ဤလုပ်ဆောင်မှုသည် အချိန်ကြာရှည်နိုင်သည်။", + "TaskCleanCollectionsAndPlaylistsDescription": "စုစည်းမှုများနှင့် အစဉ်လိုက်ပြသမှုများမှ မရှိတော့သည်များကို ဖယ်ရှားမည်။", + "TaskRefreshTrickplayImages": "ထရစ်ခ်ပလေး ပုံများကို ထုတ်မည်", + "TaskKeyframeExtractor": "ကီးဖရိန်များကို ထုတ်နုတ်ခြင်း", + "TaskCleanCollectionsAndPlaylists": "စုစည်းမှုများနှင့် အစဉ်လိုက်ပြသမှုများကို ရှင်းလင်းမည်", + "HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 16776b6bd..bae201c70 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -278,6 +278,13 @@ namespace Emby.Server.Implementations.Localization return null; } + // Convert integers directly + // This may override some of the locale specific age ratings (but those always map to the same age) + if (int.TryParse(rating, out var ratingAge)) + { + return ratingAge; + } + // Fairly common for some users to have "Rated R" in their rating field rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase); rating = rating.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs index 812df8192..f1c4d9b47 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs @@ -116,7 +116,7 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask foreach (var linkedChild in folder.LinkedChildren) { var path = linkedChild.Path; - if (!File.Exists(path)) + if (Path.HasExtension(path) ? !File.Exists(path) : !Directory.Exists(path)) { _logger.LogInformation("Item in {FolderName} cannot be found at {ItemPath}", folder.Name, path); (itemsToRemove ??= new List<LinkedChild>()).Add(linkedChild); diff --git a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index 1bac2600c..aa5fbbdf7 100644 --- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -15,10 +15,9 @@ namespace Emby.Server.Implementations.Serialization { // Need to cache these // http://dotnetcodebox.blogspot.com/2013/01/xmlserializer-class-may-result-in.html - private static readonly ConcurrentDictionary<string, XmlSerializer> _serializers = - new ConcurrentDictionary<string, XmlSerializer>(); + private readonly ConcurrentDictionary<string, XmlSerializer> _serializers = new(); - private static XmlSerializer GetSerializer(Type type) + private XmlSerializer GetSerializer(Type type) => _serializers.GetOrAdd( type.FullName ?? throw new ArgumentException($"Invalid type {type}."), static (_, t) => new XmlSerializer(t), diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index b3c93a904..aba51de8f 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -34,11 +34,6 @@ namespace Emby.Server.Implementations.Session private const float ForceKeepAliveFactor = 0.75f; /// <summary> - /// Lock used for accessing the KeepAlive cancellation token. - /// </summary> - private readonly object _keepAliveLock = new object(); - - /// <summary> /// The WebSocket watchlist. /// </summary> private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>(); @@ -55,7 +50,7 @@ namespace Emby.Server.Implementations.Session /// <summary> /// The KeepAlive cancellation token. /// </summary> - private CancellationTokenSource? _keepAliveCancellationToken; + private System.Timers.Timer _keepAlive; /// <summary> /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class. @@ -71,12 +66,34 @@ namespace Emby.Server.Implementations.Session _logger = logger; _sessionManager = sessionManager; _loggerFactory = loggerFactory; + _keepAlive = new System.Timers.Timer(TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor)) + { + AutoReset = true, + Enabled = false + }; + _keepAlive.Elapsed += KeepAliveSockets; } /// <inheritdoc /> public void Dispose() { - StopKeepAlive(); + if (_keepAlive is not null) + { + _keepAlive.Stop(); + _keepAlive.Elapsed -= KeepAliveSockets; + _keepAlive.Dispose(); + _keepAlive = null!; + } + + lock (_webSocketsLock) + { + foreach (var webSocket in _webSockets) + { + webSocket.Closed -= OnWebSocketClosed; + } + + _webSockets.Clear(); + } } /// <summary> @@ -164,7 +181,7 @@ namespace Emby.Server.Implementations.Session webSocket.Closed += OnWebSocketClosed; webSocket.LastKeepAliveDate = DateTime.UtcNow; - StartKeepAlive(); + _keepAlive.Start(); } // Notify WebSocket about timeout @@ -186,66 +203,26 @@ namespace Emby.Server.Implementations.Session { lock (_webSocketsLock) { - if (!_webSockets.Remove(webSocket)) - { - _logger.LogWarning("WebSocket {0} not on watchlist.", webSocket); - } - else + if (_webSockets.Remove(webSocket)) { webSocket.Closed -= OnWebSocketClosed; } - } - } - - /// <summary> - /// Starts the KeepAlive watcher. - /// </summary> - private void StartKeepAlive() - { - lock (_keepAliveLock) - { - if (_keepAliveCancellationToken is null) - { - _keepAliveCancellationToken = new CancellationTokenSource(); - // Start KeepAlive watcher - _ = RepeatAsyncCallbackEvery( - KeepAliveSockets, - TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor), - _keepAliveCancellationToken.Token); - } - } - } - - /// <summary> - /// Stops the KeepAlive watcher. - /// </summary> - private void StopKeepAlive() - { - lock (_keepAliveLock) - { - if (_keepAliveCancellationToken is not null) + else { - _keepAliveCancellationToken.Cancel(); - _keepAliveCancellationToken.Dispose(); - _keepAliveCancellationToken = null; + _logger.LogWarning("WebSocket {0} not on watchlist.", webSocket); } - } - lock (_webSocketsLock) - { - foreach (var webSocket in _webSockets) + if (_webSockets.Count == 0) { - webSocket.Closed -= OnWebSocketClosed; + _keepAlive.Stop(); } - - _webSockets.Clear(); } } /// <summary> /// Checks status of KeepAlive of WebSockets. /// </summary> - private async Task KeepAliveSockets() + private async void KeepAliveSockets(object? o, EventArgs? e) { List<IWebSocketConnection> inactive; List<IWebSocketConnection> lost; @@ -291,11 +268,6 @@ namespace Emby.Server.Implementations.Session RemoveWebSocket(webSocket); } } - - if (_webSockets.Count == 0) - { - StopKeepAlive(); - } } } @@ -310,29 +282,5 @@ namespace Emby.Server.Implementations.Session new ForceKeepAliveMessage(WebSocketLostTimeout), CancellationToken.None); } - - /// <summary> - /// Runs a given async callback once every specified interval time, until cancelled. - /// </summary> - /// <param name="callback">The async callback.</param> - /// <param name="interval">The interval time.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - private async Task RepeatAsyncCallbackEvery(Func<Task> callback, TimeSpan interval, CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - await callback().ConfigureAwait(false); - - try - { - await Task.Delay(interval, cancellationToken).ConfigureAwait(false); - } - catch (TaskCanceledException) - { - return; - } - } - } } } diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 1cad66326..6d94d96f3 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -194,7 +194,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) { - if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type)) + if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out _)) { _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]); displayPreferences.CustomPrefs.Remove(key); diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index d6e043e6a..4abca3271 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -162,7 +162,7 @@ public class FilterController : BaseJellyfinApiController } else if (parentId.HasValue) { - parentItem = _libraryManager.GetItemById(parentId.Value); + parentItem = _libraryManager.GetItemById<BaseItem>(parentId.Value); } var filters = new QueryFilters(); diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 6b38fa7d3..8e8accab3 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -90,6 +90,7 @@ public class ImageController : BaseJellyfinApiController /// <param name="userId">User Id.</param> /// <response code="204">Image updated.</response> /// <response code="403">User does not have permission to delete the image.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("UserImage")] [Authorize] @@ -97,6 +98,7 @@ public class ImageController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> PostUserImage( [FromQuery] Guid? userId) { @@ -289,7 +291,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] ImageType imageType, [FromQuery] int? imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -317,7 +319,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] ImageType imageType, [FromRoute] int imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -346,7 +348,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] ImageType imageType) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -390,7 +392,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] ImageType imageType, [FromRoute] int imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -433,7 +435,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] int imageIndex, [FromQuery, Required] int newIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -456,7 +458,7 @@ public class ImageController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -559,7 +561,7 @@ public class ImageController : BaseJellyfinApiController [FromQuery] string? foregroundLayer, [FromQuery] int? imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -637,7 +639,7 @@ public class ImageController : BaseJellyfinApiController [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -715,7 +717,7 @@ public class ImageController : BaseJellyfinApiController [FromQuery] string? foregroundLayer, [FromRoute, Required] int imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 3cf485299..dcbacf1d7 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -62,9 +62,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Songs/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -75,11 +77,16 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -99,9 +106,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Albums/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -112,15 +121,20 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var album = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -136,9 +150,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Playlists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -149,15 +165,20 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var playlist = (Playlist)_libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<Playlist>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -209,9 +230,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Artists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -222,11 +245,16 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -246,9 +274,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Items/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -259,11 +289,16 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -283,9 +318,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Artists/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [Obsolete("Use GetInstantMixFromArtists")] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2( [FromQuery, Required] Guid id, @@ -320,9 +357,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("MusicGenres/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( [FromQuery, Required] Guid id, [FromQuery] Guid? userId, @@ -333,11 +372,16 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(id, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index e3aee1bf7..d009f80a9 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -4,6 +4,8 @@ using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -64,7 +66,7 @@ public class ItemLookupController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -234,6 +236,7 @@ public class ItemLookupController : BaseJellyfinApiController /// <param name="searchResult">The remote search result.</param> /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param> /// <response code="204">Item metadata refreshed.</response> + /// <response code="404">Item not found.</response> /// <returns> /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. /// The task result contains an <see cref="NoContentResult"/>. @@ -241,12 +244,18 @@ public class ItemLookupController : BaseJellyfinApiController [HttpPost("Items/RemoteSearch/Apply/{itemId}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> ApplySearchCriteria( [FromRoute, Required] Guid itemId, [FromBody, Required] RemoteSearchResult searchResult, [FromQuery] bool replaceAllImages = true) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } + _logger.LogInformation( "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}", item.Id, diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index 0a8522e1c..c1343b130 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -2,7 +2,10 @@ using System; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Api; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; @@ -61,7 +64,7 @@ public class ItemRefreshController : BaseJellyfinApiController [FromQuery] bool replaceAllMetadata = false, [FromQuery] bool replaceAllImages = false) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 9800248c6..83f308bb1 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Configuration; @@ -72,7 +74,7 @@ public class ItemUpdateController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -145,7 +147,11 @@ public class ItemUpdateController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } var info = new MetadataEditorInfo { @@ -197,7 +203,7 @@ public class ItemUpdateController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 26ae1a820..6ffe6e7da 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -967,9 +967,13 @@ public class ItemsController : BaseJellyfinApiController } var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException(); - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } - return (item == null) ? NotFound() : _userDataRepository.GetUserDataDto(item, user); + return _userDataRepository.GetUserDataDto(item, user); } /// <summary> @@ -1014,8 +1018,8 @@ public class ItemsController : BaseJellyfinApiController } var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException(); - var item = _libraryManager.GetItemById(itemId); - if (item == null) + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) { return NotFound(); } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 360389d29..3b4e80ff3 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -102,7 +102,7 @@ public class LibraryController : BaseJellyfinApiController [ProducesFile("video/*", "audio/*")] public ActionResult GetFile([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -152,11 +152,10 @@ public class LibraryController : BaseJellyfinApiController ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { - return NotFound("Item not found."); + return NotFound(); } IEnumerable<BaseItem> themeItems; @@ -214,16 +213,14 @@ public class LibraryController : BaseJellyfinApiController var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var item = itemId.IsEmpty() ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { - return NotFound("Item not found."); + return NotFound(); } IEnumerable<BaseItem> themeItems; @@ -286,7 +283,8 @@ public class LibraryController : BaseJellyfinApiController userId, inheritFromParent); - if (themeSongs.Result is NotFoundObjectResult || themeVideos.Result is NotFoundObjectResult) + if (themeSongs.Result is StatusCodeResult { StatusCode: StatusCodes.Status404NotFound } + || themeVideos.Result is StatusCodeResult { StatusCode: StatusCodes.Status404NotFound }) { return NotFound(); } @@ -327,6 +325,7 @@ public class LibraryController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <response code="204">Item deleted.</response> /// <response code="401">Unauthorized access.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpDelete("Items/{itemId}")] [Authorize] @@ -335,17 +334,18 @@ public class LibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult DeleteItem(Guid itemId) { - var isApiKey = User.GetIsApiKey(); var userId = User.GetUserId(); - var user = !isApiKey && !userId.IsEmpty() - ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() - : null; - if (!isApiKey && user is null) + var isApiKey = User.GetIsApiKey(); + var user = userId.IsEmpty() && isApiKey + ? null + : _userManager.GetUserById(userId); + + if (user is null && !isApiKey) { - return Unauthorized("Unauthorized access"); + return NotFound(); } - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); @@ -391,7 +391,7 @@ public class LibraryController : BaseJellyfinApiController foreach (var i in ids) { - var item = _libraryManager.GetItemById(i); + var item = _libraryManager.GetItemById<BaseItem>(i, user); if (item is null) { return NotFound(); @@ -459,20 +459,18 @@ public class LibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) { - var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); - + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { - return NotFound("Item not found"); + return NotFound(); } var baseItemDtos = new List<BaseItemDto>(); - var user = userId.IsNullOrEmpty() - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions().AddClientFields(User); BaseItem? parent = item.GetParent(); @@ -644,14 +642,16 @@ public class LibraryController : BaseJellyfinApiController [ProducesFile("video/*", "audio/*")] public async Task<ActionResult> GetDownload([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var userId = User.GetUserId(); + var user = userId.IsEmpty() + ? null + : _userManager.GetUserById(userId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - var user = _userManager.GetUserById(User.GetUserId()); - if (user is not null) { if (!item.CanDownload(user)) @@ -704,12 +704,14 @@ public class LibraryController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) { userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); var item = itemId.IsEmpty() - ? (userId.IsNullOrEmpty() + ? (user is null ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); @@ -720,9 +722,6 @@ public class LibraryController : BaseJellyfinApiController return new QueryResult<BaseItemDto>(); } - var user = userId.IsNullOrEmpty() - ? null - : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User); diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 23c430f85..f685eeaa0 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -6,6 +6,8 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; using MediaBrowser.Common.Api; @@ -73,7 +75,7 @@ public class LibraryStructureController : BaseJellyfinApiController [HttpPost] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> AddVirtualFolder( - [FromQuery] string? name, + [FromQuery] string name, [FromQuery] CollectionTypeOptions? collectionType, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, [FromBody] AddVirtualFolderDto? libraryOptionsDto, @@ -101,7 +103,7 @@ public class LibraryStructureController : BaseJellyfinApiController [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> RemoveVirtualFolder( - [FromQuery] string? name, + [FromQuery] string name, [FromQuery] bool refreshLibrary = false) { await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); @@ -265,18 +267,16 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="refreshLibrary">Whether to refresh the library.</param> /// <returns>A <see cref="NoContentResult"/>.</returns> /// <response code="204">Media path removed.</response> - /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + /// <exception cref="ArgumentException">The name of the library and path may not be empty.</exception> [HttpDelete("Paths")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult RemoveMediaPath( - [FromQuery] string? name, - [FromQuery] string? path, + [FromQuery] string name, + [FromQuery] string path, [FromQuery] bool refreshLibrary = false) { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(path); _libraryMonitor.Stop(); @@ -311,15 +311,21 @@ public class LibraryStructureController : BaseJellyfinApiController /// </summary> /// <param name="request">The library name and options.</param> /// <response code="204">Library updated.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("LibraryOptions")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateLibraryOptions( [FromBody] UpdateLibraryOptionsDto request) { - var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id); + var item = _libraryManager.GetItemById<CollectionFolder>(request.Id, User.GetUserId()); + if (item is null) + { + return NotFound(); + } - collectionFolder.UpdateLibraryOptions(request.LibraryOptions); + item.UpdateLibraryOptions(request.LibraryOptions); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 7768b3c45..2b26c01f8 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -220,9 +220,11 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="channelId">Channel id.</param> /// <param name="userId">Optional. Attach user data.</param> /// <response code="200">Live tv channel returned.</response> + /// <response code="404">Item not found.</response> /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns> [HttpGet("Channels/{channelId}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [Authorize(Policy = Policies.LiveTvAccess)] public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId) { @@ -232,7 +234,12 @@ public class LiveTvController : BaseJellyfinApiController : _userManager.GetUserById(userId.Value); var item = channelId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(channelId); + : _libraryManager.GetItemById<BaseItem>(channelId, user); + + if (item is null) + { + return NotFound(); + } var dtoOptions = new DtoOptions() .AddClientFields(User); @@ -416,9 +423,11 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="recordingId">Recording id.</param> /// <param name="userId">Optional. Attach user data.</param> /// <response code="200">Recording returned.</response> + /// <response code="404">Item not found.</response> /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns> [HttpGet("Recordings/{recordingId}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [Authorize(Policy = Policies.LiveTvAccess)] public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId) { @@ -426,7 +435,13 @@ public class LiveTvController : BaseJellyfinApiController var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var item = recordingId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId); + var item = recordingId.IsEmpty() + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById<BaseItem>(recordingId, user); + if (item is null) + { + return NotFound(); + } var dtoOptions = new DtoOptions() .AddClientFields(User); @@ -611,7 +626,8 @@ public class LiveTvController : BaseJellyfinApiController { query.IsSeries = true; - if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series) + var series = _libraryManager.GetItemById<Series>(librarySeriesId.Value); + if (series is not null) { query.Name = series.Name; } @@ -665,7 +681,8 @@ public class LiveTvController : BaseJellyfinApiController { query.IsSeries = true; - if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series) + var series = _libraryManager.GetItemById<Series>(body.LibrarySeriesId); + if (series is not null) { query.Name = series.Name; } @@ -779,7 +796,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult DeleteRecording([FromRoute, Required] Guid recordingId) { - var item = _libraryManager.GetItemById(recordingId); + var item = _libraryManager.GetItemById<BaseItem>(recordingId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs index f2b312b47..8eb4cadf8 100644 --- a/Jellyfin.Api/Controllers/LyricsController.cs +++ b/Jellyfin.Api/Controllers/LyricsController.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Entities.Audio; @@ -66,37 +67,16 @@ public class LyricsController : BaseJellyfinApiController [HttpGet("Audio/{itemId}/Lyrics")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<LyricDto>> GetLyrics([FromRoute, Required] Guid itemId) { - var isApiKey = User.GetIsApiKey(); - var userId = User.GetUserId(); - if (!isApiKey && userId.IsEmpty()) - { - return BadRequest(); - } - - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } - if (!isApiKey) - { - var user = _userManager.GetUserById(userId); - if (user is null) - { - return NotFound(); - } - - // Check the item is visible for the user - if (!audio.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {audio.Name}."); - } - } - - var result = await _lyricManager.GetLyricsAsync(audio, CancellationToken.None).ConfigureAwait(false); + var result = await _lyricManager.GetLyricsAsync(item, CancellationToken.None).ConfigureAwait(false); if (result is not null) { return Ok(result); @@ -124,8 +104,8 @@ public class LyricsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery, Required] string fileName) { - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } @@ -147,7 +127,7 @@ public class LyricsController : BaseJellyfinApiController { await Request.Body.CopyToAsync(stream).ConfigureAwait(false); var uploadedLyric = await _lyricManager.SaveLyricAsync( - audio, + item, format, stream) .ConfigureAwait(false); @@ -157,7 +137,7 @@ public class LyricsController : BaseJellyfinApiController return BadRequest(); } - _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); return Ok(uploadedLyric); } } @@ -176,13 +156,13 @@ public class LyricsController : BaseJellyfinApiController public async Task<ActionResult> DeleteLyrics( [FromRoute, Required] Guid itemId) { - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } - await _lyricManager.DeleteLyricsAsync(audio).ConfigureAwait(false); + await _lyricManager.DeleteLyricsAsync(item).ConfigureAwait(false); return NoContent(); } @@ -199,13 +179,13 @@ public class LyricsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<IReadOnlyList<RemoteLyricInfoDto>>> SearchRemoteLyrics([FromRoute, Required] Guid itemId) { - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } - var results = await _lyricManager.SearchLyricsAsync(audio, false, CancellationToken.None).ConfigureAwait(false); + var results = await _lyricManager.SearchLyricsAsync(item, false, CancellationToken.None).ConfigureAwait(false); return Ok(results); } @@ -225,19 +205,19 @@ public class LyricsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] string lyricId) { - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } - var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(audio, lyricId, CancellationToken.None).ConfigureAwait(false); + var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(item, lyricId, CancellationToken.None).ConfigureAwait(false); if (downloadedLyrics is null) { return NotFound(); } - _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); return Ok(downloadedLyrics); } diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 742012b71..bc52be184 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -8,8 +8,10 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.MediaInfoDtos; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.MediaInfo; using Microsoft.AspNetCore.Authorization; @@ -32,6 +34,7 @@ public class MediaInfoController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly ILogger<MediaInfoController> _logger; private readonly MediaInfoHelper _mediaInfoHelper; + private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="MediaInfoController"/> class. @@ -41,18 +44,21 @@ public class MediaInfoController : BaseJellyfinApiController /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param> /// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface..</param> public MediaInfoController( IMediaSourceManager mediaSourceManager, IDeviceManager deviceManager, ILibraryManager libraryManager, ILogger<MediaInfoController> logger, - MediaInfoHelper mediaInfoHelper) + MediaInfoHelper mediaInfoHelper, + IUserManager userManager) { _mediaSourceManager = mediaSourceManager; _deviceManager = deviceManager; _libraryManager = libraryManager; _logger = logger; _mediaInfoHelper = mediaInfoHelper; + _userManager = userManager; } /// <summary> @@ -61,16 +67,24 @@ public class MediaInfoController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <param name="userId">The user id.</param> /// <response code="200">Playback info returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns> [HttpGet("Items/{itemId}/PlaybackInfo")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - return await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId) - .ConfigureAwait(false); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + + return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false); } /// <summary> @@ -97,9 +111,11 @@ public class MediaInfoController : BaseJellyfinApiController /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param> /// <param name="playbackInfoDto">The playback info.</param> /// <response code="200">Playback info returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns> [HttpPost("Items/{itemId}/PlaybackInfo")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo( [FromRoute, Required] Guid itemId, [FromQuery, ParameterObsolete] Guid? userId, @@ -148,9 +164,19 @@ public class MediaInfoController : BaseJellyfinApiController allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true; allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true; + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, + item, + user, mediaSourceId, liveStreamId) .ConfigureAwait(false); @@ -163,8 +189,6 @@ public class MediaInfoController : BaseJellyfinApiController if (profile is not null) { // set device specific data - var item = _libraryManager.GetItemById(itemId); - foreach (var mediaSource in info.MediaSources) { _mediaInfoHelper.SetDeviceSpecificData( diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 1100f85cf..abf94a32f 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -482,8 +482,13 @@ public class PlaylistsController : BaseJellyfinApiController var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<Playlist>(playlistId, user); + if (item is null) + { + return NotFound(); + } - var items = playlist.GetManageableItems().ToArray(); + var items = item.GetManageableItems().ToArray(); var count = items.Length; if (startIndex.HasValue) { diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 949d101dc..9d6d75681 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -6,6 +6,7 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -76,21 +77,21 @@ public class PlaystateController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } - var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var dto = UpdatePlayedStatus(user, item, true, datePlayed); foreach (var additionalUserInfo in session.AdditionalUsers) { @@ -141,21 +142,21 @@ public class PlaystateController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } - var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var item = _libraryManager.GetItemById(itemId); - + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var dto = UpdatePlayedStatus(user, item, false, null); foreach (var additionalUserInfo in session.AdditionalUsers) { diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index 595cab2df..a476005cb 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -6,8 +6,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Api; using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -68,7 +71,7 @@ public class RemoteImageController : BaseJellyfinApiController [FromQuery] string? providerName, [FromQuery] bool includeAllLanguages = false) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -127,7 +130,7 @@ public class RemoteImageController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -154,7 +157,7 @@ public class RemoteImageController : BaseJellyfinApiController [FromQuery, Required] ImageType type, [FromQuery] string? imageUrl) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 413b7b834..8bae6fb9b 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -211,7 +211,7 @@ public class SearchController : BaseJellyfinApiController if (!item.ChannelId.IsEmpty()) { - var channel = _libraryManager.GetItemById(item.ChannelId); + var channel = _libraryManager.GetItemById<BaseItem>(item.ChannelId); result.ChannelName = channel?.Name; } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index e2c5486d9..9da1dce93 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -12,6 +12,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.SubtitleDtos; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; @@ -95,8 +96,7 @@ public class SubtitleController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] int index) { - var item = _libraryManager.GetItemById(itemId); - + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -113,18 +113,24 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="language">The language of the subtitles.</param> /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> /// <response code="200">Subtitles retrieved.</response> + /// <response code="404">Item not found.</response> /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] [Authorize(Policy = Policies.SubtitleManagement)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( [FromRoute, Required] Guid itemId, [FromRoute, Required] string language, [FromQuery] bool? isPerfectMatch) { - var video = (Video)_libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } - return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); + return await _subtitleManager.SearchSubtitles(item, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); } /// <summary> @@ -133,22 +139,28 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <param name="subtitleId">The subtitle id.</param> /// <response code="204">Subtitle downloaded.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] [Authorize(Policy = Policies.SubtitleManagement)] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> DownloadRemoteSubtitles( [FromRoute, Required] Guid itemId, [FromRoute, Required] string subtitleId) { - var video = (Video)_libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } try { - await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) + await _subtitleManager.DownloadSubtitles(item, subtitleId, CancellationToken.None) .ConfigureAwait(false); - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); } catch (Exception ex) { @@ -223,7 +235,7 @@ public class SubtitleController : BaseJellyfinApiController if (string.IsNullOrEmpty(format)) { - var item = (Video)_libraryManager.GetItemById(itemId.Value); + var item = _libraryManager.GetItemById<Video>(itemId.Value); var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) @@ -321,10 +333,12 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="mediaSourceId">The media source id.</param> /// <param name="segmentLength">The subtitle segment length.</param> /// <response code="200">Subtitle playlist retrieved.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesPlaylistFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] public async Task<ActionResult> GetSubtitlePlaylist( @@ -333,7 +347,11 @@ public class SubtitleController : BaseJellyfinApiController [FromRoute, Required] string mediaSourceId, [FromQuery, Required] int segmentLength) { - var item = (Video)_libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); @@ -397,15 +415,21 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="itemId">The item the subtitle belongs to.</param> /// <param name="body">The request body.</param> /// <response code="204">Subtitle uploaded.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Videos/{itemId}/Subtitles")] [Authorize(Policy = Policies.SubtitleManagement)] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> UploadSubtitle( [FromRoute, Required] Guid itemId, [FromBody, Required] UploadSubtitleDto body) { - var video = (Video)_libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } var bytes = Encoding.UTF8.GetBytes(body.Data); var memoryStream = new MemoryStream(bytes, 0, bytes.Length, false, true); @@ -416,7 +440,7 @@ public class SubtitleController : BaseJellyfinApiController await using (stream.ConfigureAwait(false)) { await _subtitleManager.UploadSubtitle( - video, + item, new SubtitleResponse { Format = body.Format, @@ -425,7 +449,7 @@ public class SubtitleController : BaseJellyfinApiController IsHearingImpaired = body.IsHearingImpaired, Stream = stream }).ConfigureAwait(false); - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); return NoContent(); } @@ -452,7 +476,7 @@ public class SubtitleController : BaseJellyfinApiController long? endPositionTicks, bool copyTimestamps) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById<BaseItem>(id); return _subtitleEncoder.GetSubtitles( item, diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs index 2dc960229..0afe053da 100644 --- a/Jellyfin.Api/Controllers/TrickplayController.cs +++ b/Jellyfin.Api/Controllers/TrickplayController.cs @@ -5,6 +5,8 @@ using System.Text; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model; @@ -84,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController [FromRoute, Required] int index, [FromQuery] Guid? mediaSourceId) { - var item = _libraryManager.GetItemById(mediaSourceId ?? itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 3d84b61bf..68b4b6b8b 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -234,7 +234,7 @@ public class TvShowsController : BaseJellyfinApiController if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. { - var item = _libraryManager.GetItemById(seasonId.Value); + var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value); if (item is not Season seasonItem) { return NotFound("No season exists with Id " + seasonId); @@ -244,7 +244,8 @@ public class TvShowsController : BaseJellyfinApiController } else if (season.HasValue) // Season number was supplied. Get episodes by season number { - if (_libraryManager.GetItemById(seriesId) is not Series series) + var series = _libraryManager.GetItemById<Series>(seriesId); + if (series is null) { return NotFound("Series not found"); } @@ -259,7 +260,7 @@ public class TvShowsController : BaseJellyfinApiController } else // No season number or season id was supplied. Returning all episodes. { - if (_libraryManager.GetItemById(seriesId) is not Series series) + if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series) { return NotFound("Series not found"); } @@ -342,13 +343,13 @@ public class TvShowsController : BaseJellyfinApiController var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - - if (_libraryManager.GetItemById(seriesId) is not Series series) + var item = _libraryManager.GetItemById<Series>(seriesId, user); + if (item is null) { - return NotFound("Series not found"); + return NotFound(); } - var seasons = series.GetItemList(new InternalItemsQuery(user) + var seasons = item.GetItemList(new InternalItemsQuery(user) { IsMissing = isMissing, IsSpecialSeason = isSpecialSeason, diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index db78e9946..1d4adae06 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -9,7 +9,9 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; @@ -33,6 +35,7 @@ public class UniversalAudioController : BaseJellyfinApiController private readonly MediaInfoHelper _mediaInfoHelper; private readonly AudioHelper _audioHelper; private readonly DynamicHlsHelper _dynamicHlsHelper; + private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="UniversalAudioController"/> class. @@ -42,18 +45,21 @@ public class UniversalAudioController : BaseJellyfinApiController /// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param> /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> public UniversalAudioController( ILibraryManager libraryManager, ILogger<UniversalAudioController> logger, MediaInfoHelper mediaInfoHelper, AudioHelper audioHelper, - DynamicHlsHelper dynamicHlsHelper) + DynamicHlsHelper dynamicHlsHelper, + IUserManager userManager) { _libraryManager = libraryManager; _logger = logger; _mediaInfoHelper = mediaInfoHelper; _audioHelper = audioHelper; _dynamicHlsHelper = dynamicHlsHelper; + _userManager = userManager; } /// <summary> @@ -79,12 +85,14 @@ public class UniversalAudioController : BaseJellyfinApiController /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param> /// <response code="200">Audio stream returned.</response> /// <response code="302">Redirected to remote audio stream.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="Task"/> containing the audio file.</returns> [HttpGet("Audio/{itemId}/universal")] [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesAudioFile] public async Task<ActionResult> GetUniversalAudioStream( [FromRoute, Required] Guid itemId, @@ -106,20 +114,27 @@ public class UniversalAudioController : BaseJellyfinApiController [FromQuery] bool breakOnNonKeyFrames = false, [FromQuery] bool enableRedirection = true) { - var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + + var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); var info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, + item, + user, mediaSourceId) .ConfigureAwait(false); // set device specific data - var item = _libraryManager.GetItemById(itemId); - foreach (var sourceInfo in info.MediaSources) { _mediaInfoHelper.SetDeviceSpecificData( diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index c19ad33c8..421f1bfb5 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -77,8 +77,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -86,20 +86,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); var dtoOptions = new DtoOptions().AddClientFields(User); @@ -133,8 +125,8 @@ public class UserLibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<BaseItemDto> GetRootFolder([FromQuery] Guid? userId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -172,8 +164,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -181,20 +173,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); var dtoOptions = new DtoOptions().AddClientFields(User); var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); @@ -231,8 +215,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -240,20 +224,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - return MarkFavorite(user, item, true); } @@ -286,8 +262,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -295,20 +271,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - return MarkFavorite(user, item, false); } @@ -341,8 +309,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -350,20 +318,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - return UpdateUserItemRatingInternal(user, item, null); } @@ -398,8 +358,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -407,20 +367,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - return UpdateUserItemRatingInternal(user, item, likes); } @@ -455,8 +407,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -464,20 +416,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - var dtoOptions = new DtoOptions().AddClientFields(User); if (item is IHasTrailers hasTrailers) { @@ -519,8 +463,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -528,20 +472,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - var dtoOptions = new DtoOptions().AddClientFields(User); return Ok(item diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index 23b9ba46f..b67c6fdb7 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -4,7 +4,10 @@ using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using Microsoft.AspNetCore.Http; @@ -54,7 +57,7 @@ public class VideoAttachmentsController : BaseJellyfinApiController { try { - var item = _libraryManager.GetItemById(videoId); + var item = _libraryManager.GetItemById<BaseItem>(videoId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 380120032..a9e1d4484 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -7,7 +7,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -105,7 +104,11 @@ public class VideosController : BaseJellyfinApiController ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); + : _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } var dtoOptions = new DtoOptions(); dtoOptions = dtoOptions.AddClientFields(User); @@ -139,24 +142,23 @@ public class VideosController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId) { - var video = (Video)_libraryManager.GetItemById(itemId); - - if (video is null) + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) { - return NotFound("The video either does not exist or the id does not belong to a video."); + return NotFound(); } - if (video.LinkedAlternateVersions.Length == 0) + if (item.LinkedAlternateVersions.Length == 0) { - video = (Video?)_libraryManager.GetItemById(video.PrimaryVersionId); + item = _libraryManager.GetItemById<Video>(Guid.Parse(item.PrimaryVersionId)); } - if (video is null) + if (item is null) { return NotFound(); } - foreach (var link in video.GetLinkedAlternateVersions()) + foreach (var link in item.GetLinkedAlternateVersions()) { link.SetPrimaryVersionId(null); link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); @@ -164,9 +166,9 @@ public class VideosController : BaseJellyfinApiController await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } - video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - video.SetPrimaryVersionId(null); - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + item.SetPrimaryVersionId(null); + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); return NoContent(); } @@ -184,8 +186,9 @@ public class VideosController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { + var userId = User.GetUserId(); var items = ids - .Select(i => _libraryManager.GetItemById(i)) + .Select(i => _libraryManager.GetItemById<BaseItem>(i, userId)) .OfType<Video>() .OrderBy(i => i.Id) .ToList(); diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 6a24ad32a..212d678a8 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -24,6 +24,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Helpers; @@ -76,21 +77,17 @@ public class MediaInfoHelper /// <summary> /// Get playback info. /// </summary> - /// <param name="id">Item id.</param> - /// <param name="userId">User Id.</param> + /// <param name="item">The item.</param> + /// <param name="user">The user.</param> /// <param name="mediaSourceId">Media source id.</param> /// <param name="liveStreamId">Live stream id.</param> /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> public async Task<PlaybackInfoResponse> GetPlaybackInfo( - Guid id, - Guid? userId, + BaseItem item, + User? user, string? mediaSourceId = null, string? liveStreamId = null) { - var user = userId.IsNullOrEmpty() - ? null - : _userManager.GetUserById(userId.Value); - var item = _libraryManager.GetItemById(id); var result = new PlaybackInfoResponse(); MediaSourceInfo[] mediaSources; @@ -402,7 +399,8 @@ public class MediaInfoHelper if (profile is not null) { - var item = _libraryManager.GetItemById(request.ItemId); + var item = _libraryManager.GetItemById<BaseItem>(request.ItemId) + ?? throw new ResourceNotFoundException(); SetDeviceSpecificData( item, diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index bfe71fd87..d6d7a56eb 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -11,6 +11,7 @@ using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; @@ -18,6 +19,7 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Net.Http.Headers; namespace Jellyfin.Api.Helpers; @@ -107,7 +109,8 @@ public static class StreamingHelpers ?? state.SupportedSubtitleCodecs.FirstOrDefault(); } - var item = libraryManager.GetItemById(streamingRequest.Id); + var item = libraryManager.GetItemById<BaseItem>(streamingRequest.Id) + ?? throw new ResourceNotFoundException(); state.IsInputVideo = item.MediaType == MediaType.Video; @@ -125,7 +128,7 @@ public static class StreamingHelpers if (mediaSource is null) { - var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false); + var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById<BaseItem>(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false); mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId) ? mediaSources[0] diff --git a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs index 94ffc5238..7a549aada 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs @@ -12,7 +12,7 @@ public class MediaPathDto /// Gets or sets the name of the library. /// </summary> [Required] - public string? Name { get; set; } + public required string Name { get; set; } /// <summary> /// Gets or sets the path to add. diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs index bb8d4dd14..3d747f2ea 100644 --- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -22,11 +22,9 @@ public static class ServiceCollectionExtensions serviceCollection.AddEFSecondLevelCache(options => options.UseMemoryCacheProvider() .CacheAllQueries(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(10)) - .DisableLogging(true) .UseCacheKeyPrefix("EF_") // Don't cache null values. Remove this optional setting if it's not necessary. - .SkipCachingResults(result => - result.Value is null || (result.Value is EFTableRows rows && rows.RowsCount == 0))); + .SkipCachingResults(result => result.Value is null or EFTableRows { RowsCount: 0 })); serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) => { diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs index 9137ea234..52fb93d59 100644 --- a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs +++ b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs @@ -42,7 +42,7 @@ namespace Jellyfin.Server.Migrations.Routines } var libraryOptions = virtualFolder.LibraryOptions; - var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(folderId); + var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId) ?? throw new InvalidOperationException("Failed to find CollectionFolder"); // The property no longer exists in LibraryOptions, so we just re-save the options to get old data removed. collectionFolder.UpdateLibraryOptions(libraryOptions); _logger.LogInformation("Removed from '{VirtualFolder}'", virtualFolder.Name); diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 6532f7a34..37703ceee 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CA1002, CS1591 using System; @@ -33,17 +31,17 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Occurs when [item added]. /// </summary> - event EventHandler<ItemChangeEventArgs> ItemAdded; + event EventHandler<ItemChangeEventArgs>? ItemAdded; /// <summary> /// Occurs when [item updated]. /// </summary> - event EventHandler<ItemChangeEventArgs> ItemUpdated; + event EventHandler<ItemChangeEventArgs>? ItemUpdated; /// <summary> /// Occurs when [item removed]. /// </summary> - event EventHandler<ItemChangeEventArgs> ItemRemoved; + event EventHandler<ItemChangeEventArgs>? ItemRemoved; /// <summary> /// Gets the root folder. @@ -60,10 +58,10 @@ namespace MediaBrowser.Controller.Library /// <param name="parent">The parent.</param> /// <param name="directoryService">An instance of <see cref="IDirectoryService"/>.</param> /// <returns>BaseItem.</returns> - BaseItem ResolvePath( + BaseItem? ResolvePath( FileSystemMetadata fileInfo, - Folder parent = null, - IDirectoryService directoryService = null); + Folder? parent = null, + IDirectoryService? directoryService = null); /// <summary> /// Resolves a set of files into a list of BaseItem. @@ -86,7 +84,7 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="name">The name of the person.</param> /// <returns>Task{Person}.</returns> - Person GetPerson(string name); + Person? GetPerson(string name); /// <summary> /// Finds the by path. @@ -94,7 +92,7 @@ namespace MediaBrowser.Controller.Library /// <param name="path">The path.</param> /// <param name="isFolder"><c>true</c> is the path is a directory; otherwise <c>false</c>.</param> /// <returns>BaseItem.</returns> - BaseItem FindByPath(string path, bool? isFolder); + BaseItem? FindByPath(string path, bool? isFolder); /// <summary> /// Gets the artist. @@ -166,7 +164,8 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="id">The id.</param> /// <returns>BaseItem.</returns> - BaseItem GetItemById(Guid id); + /// <exception cref="ArgumentNullException"><paramref name="id"/> is <c>null</c>.</exception> + BaseItem? GetItemById(Guid id); /// <summary> /// Gets the item by id, as T. @@ -174,7 +173,27 @@ namespace MediaBrowser.Controller.Library /// <param name="id">The item id.</param> /// <typeparam name="T">The type of item.</typeparam> /// <returns>The item.</returns> - T GetItemById<T>(Guid id) + T? GetItemById<T>(Guid id) + where T : BaseItem; + + /// <summary> + /// Gets the item by id, as T, and validates user access. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">The user id to validate against.</param> + /// <typeparam name="T">The type of item.</typeparam> + /// <returns>The item if found.</returns> + public T? GetItemById<T>(Guid id, Guid userId) + where T : BaseItem; + + /// <summary> + /// Gets the item by id, as T, and validates user access. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="user">The user to validate against.</param> + /// <typeparam name="T">The type of item.</typeparam> + /// <returns>The item if found.</returns> + public T? GetItemById<T>(Guid id, User? user) where T : BaseItem; /// <summary> @@ -208,9 +227,9 @@ namespace MediaBrowser.Controller.Library /// <param name="sortBy">The sort by.</param> /// <param name="sortOrder">The sort order.</param> /// <returns>IEnumerable{BaseItem}.</returns> - IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder); + IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder); - IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy); + IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy); /// <summary> /// Gets the user root folder. @@ -223,7 +242,7 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="item">Item to create.</param> /// <param name="parent">Parent of new item.</param> - void CreateItem(BaseItem item, BaseItem parent); + void CreateItem(BaseItem item, BaseItem? parent); /// <summary> /// Creates the items. @@ -231,7 +250,7 @@ namespace MediaBrowser.Controller.Library /// <param name="items">Items to create.</param> /// <param name="parent">Parent of new items.</param> /// <param name="cancellationToken">CancellationToken to use for operation.</param> - void CreateItems(IReadOnlyList<BaseItem> items, BaseItem parent, CancellationToken cancellationToken); + void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken); /// <summary> /// Updates the item. @@ -509,7 +528,7 @@ namespace MediaBrowser.Controller.Library /// <returns>QueryResult<BaseItem>.</returns> QueryResult<BaseItem> QueryItems(InternalItemsQuery query); - string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem = null); + string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem = null); /// <summary> /// Converts the image to local. diff --git a/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs b/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs index a07b3e898..733d40ba1 100644 --- a/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs +++ b/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs @@ -14,6 +14,6 @@ namespace MediaBrowser.Controller.Resolvers /// <param name="fileInfo">The file information.</param> /// <param name="parent">The parent BaseItem.</param> /// <returns>True if the file should be ignored.</returns> - bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent); + bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent); } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 4b1b1bbc6..21f4cb1cd 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -463,7 +463,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles try { var subtitleStreams = mediaSource.MediaStreams - .Where(stream => stream.IsTextSubtitleStream && stream.SupportsExternalStream); + .Where(stream => stream is { IsTextSubtitleStream: true, SupportsExternalStream: true, IsExternal: false }); foreach (var subtitleStream in subtitleStreams) { diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index a07a0f41b..ea5dbf7f7 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -492,12 +492,11 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + await JsonSerializer.SerializeAsync(logStream, state.MediaSource, cancellationToken: cancellationTokenSource.Token).ConfigureAwait(false); var commandLineLogMessageBytes = Encoding.UTF8.GetBytes( - JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine - + Environment.NewLine - + commandLineLogMessage + + process.StartInfo.FileName + " " + process.StartInfo.Arguments + Environment.NewLine + Environment.NewLine); diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 67b84681d..bbc9af227 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -338,6 +338,12 @@ namespace MediaBrowser.Providers.MediaInfo audio.Artists = performers; } + if (albumArtists.Length == 0) + { + // Album artists not provided, fall back to performers (artists). + albumArtists = performers; + } + if (options.ReplaceAllMetadata && albumArtists.Length != 0) { audio.AlbumArtists = albumArtists; diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index 1399ac307..b25cfc83f 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -947,7 +947,7 @@ namespace MediaBrowser.XbmcMetadata.Savers if (saveImagePath) { var personEntity = libraryManager.GetPerson(person.Name); - var image = personEntity.GetImageInfo(ImageType.Primary, 0); + var image = personEntity?.GetImageInfo(ImageType.Primary, 0); if (image is not null) { diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 4f6ed4469..75963226a 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -263,6 +263,11 @@ public class SkiaEncoder : IImageEncoder return null; } + if (codec.FrameCount != 0) + { + throw new ArgumentException("Cannot decode images with multiple frames"); + } + // create the bitmap var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); diff --git a/src/Jellyfin.Networking/AutoDiscoveryHost.cs b/src/Jellyfin.Networking/AutoDiscoveryHost.cs index 2be57d7a1..f1b79b4b4 100644 --- a/src/Jellyfin.Networking/AutoDiscoveryHost.cs +++ b/src/Jellyfin.Networking/AutoDiscoveryHost.cs @@ -11,6 +11,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Model.ApiClient; +using MediaBrowser.Model.Net; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -50,6 +51,25 @@ public sealed class AutoDiscoveryHost : BackgroundService _networkManager = networkManager; } + private static IPAddress GetBindAddress(IPData intf) + { + if (intf.AddressFamily == AddressFamily.InterNetwork) + { + if (OperatingSystem.IsLinux()) + { + return NetworkUtils.GetBroadcastAddress(intf.Subnet); + } + + if (OperatingSystem.IsMacOS()) + { + // macOS kernel does not allow bind to 127:255:255:255 + return IPAddress.IsLoopback(intf.Address) ? intf.Address : NetworkUtils.GetBroadcastAddress(intf.Subnet); + } + } + + return intf.Address; + } + /// <inheritdoc /> protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -63,24 +83,23 @@ public sealed class AutoDiscoveryHost : BackgroundService // Linux needs to bind to the broadcast addresses to receive broadcast traffic if (OperatingSystem.IsLinux() && networkConfig.EnableIPv4) { - udpServers.Add(ListenForAutoDiscoveryMessage(IPAddress.Broadcast, stoppingToken)); + udpServers.Add(ListenForAutoDiscoveryMessage(IPAddress.Broadcast, IPAddress.Broadcast, stoppingToken)); } udpServers.AddRange(_networkManager.GetInternalBindAddresses() .Select(intf => ListenForAutoDiscoveryMessage( - OperatingSystem.IsLinux() && intf.AddressFamily == AddressFamily.InterNetwork - ? NetworkUtils.GetBroadcastAddress(intf.Subnet) - : intf.Address, + GetBindAddress(intf), + intf.Address, stoppingToken))); await Task.WhenAll(udpServers).ConfigureAwait(false); } - private async Task ListenForAutoDiscoveryMessage(IPAddress address, CancellationToken cancellationToken) + private async Task ListenForAutoDiscoveryMessage(IPAddress listenAddress, IPAddress respondAddress, CancellationToken cancellationToken) { try { - using var udpClient = new UdpClient(new IPEndPoint(address, PortNumber)); + using var udpClient = new UdpClient(new IPEndPoint(listenAddress, PortNumber)); udpClient.MulticastLoopback = false; while (!cancellationToken.IsCancellationRequested) @@ -91,7 +110,7 @@ public sealed class AutoDiscoveryHost : BackgroundService var text = Encoding.UTF8.GetString(result.Buffer); if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase)) { - await RespondToV2Message(udpClient, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); + await RespondToV2Message(respondAddress, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); } } catch (SocketException ex) @@ -107,11 +126,11 @@ public sealed class AutoDiscoveryHost : BackgroundService catch (Exception ex) { // Exception in this function will prevent the background service from restarting in-process. - _logger.LogError(ex, "Unable to bind to {Address}:{Port}", address, PortNumber); + _logger.LogError(ex, "Unable to bind to {Address}:{Port}", listenAddress, PortNumber); } } - private async Task RespondToV2Message(UdpClient udpClient, IPEndPoint endpoint, CancellationToken cancellationToken) + private async Task RespondToV2Message(IPAddress responderIp, IPEndPoint endpoint, CancellationToken cancellationToken) { var localUrl = _appHost.GetSmartApiUrl(endpoint.Address); if (string.IsNullOrEmpty(localUrl)) @@ -122,10 +141,11 @@ public sealed class AutoDiscoveryHost : BackgroundService var response = new ServerDiscoveryInfo(localUrl, _appHost.SystemId, _appHost.FriendlyName); + using var responder = new UdpClient(new IPEndPoint(responderIp, PortNumber)); try { _logger.LogDebug("Sending AutoDiscovery response"); - await udpClient + await responder .SendAsync(JsonSerializer.SerializeToUtf8Bytes(response).AsMemory(), endpoint, cancellationToken) .ConfigureAwait(false); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index d1be07aa2..940e3c2b1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -18,6 +18,12 @@ namespace Jellyfin.Server.Implementations.Tests.Library [InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid-618355][imdbid-tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid-618355][imdbid-tt10985510]", "tmdbid", "618355")] + [InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")] + [InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")] + [InlineData("Superman: Red Son [providera id=4]", "providera id", "4")] + [InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")] + [InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")] + [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")] [InlineData("[tmdbid=618355]", "tmdbid", "618355")] [InlineData("[tmdbid-618355]", "tmdbid", "618355")] [InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 09e4709da..0f7f5c194 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -127,6 +127,22 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Equal(expectedLevel, level!); } + [Theory] + [InlineData("0", 0)] + [InlineData("1", 1)] + [InlineData("6", 6)] + [InlineData("12", 12)] + [InlineData("42", 42)] + [InlineData("9999", 9999)] + public async Task GetRatingLevel_GivenValidAge_Success(string value, int expectedLevel) + { + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" }); + await localizationManager.LoadAll(); + var level = localizationManager.GetRatingLevel(value); + Assert.NotNull(level); + Assert.Equal(expectedLevel, level); + } + [Fact] public async Task GetRatingLevel_GivenUnratedString_Success() { |
