diff --git a/.ci/azure-pipelines-abi.yml b/.ci/azure-pipelines-abi.yml index 4d38a906e6..b558d2a6f0 100644 --- a/.ci/azure-pipelines-abi.yml +++ b/.ci/azure-pipelines-abi.yml @@ -62,6 +62,7 @@ jobs: - task: DownloadPipelineArtifact@2 displayName: 'Download Reference Assembly Build Artifact' + enabled: false inputs: source: "specific" artifact: "$(NugetPackageName)" @@ -73,6 +74,7 @@ jobs: - task: CopyFiles@2 displayName: 'Copy Reference Assembly Build Artifact' + enabled: false inputs: sourceFolder: $(System.ArtifactsDirectory)/current-artifacts contents: '**/*.dll' @@ -83,6 +85,7 @@ jobs: - task: DotNetCoreCLI@2 displayName: 'Execute ABI Compatibility Check Tool' + enabled: false inputs: command: custom custom: compat diff --git a/.ci/azure-pipelines-api-client.yml b/.ci/azure-pipelines-api-client.yml index fc89b90d4e..03102121ff 100644 --- a/.ci/azure-pipelines-api-client.yml +++ b/.ci/azure-pipelines-api-client.yml @@ -28,15 +28,13 @@ jobs: inputs: script: "wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${{ parameters.GeneratorVersion }}/openapi-generator-cli-${{ parameters.GeneratorVersion }}.jar -O openapi-generator-cli.jar" -## Generate npm api client -# Unstable - - task: CmdLine@2 - displayName: 'Build unstable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') +## Authenticate with npm registry + - task: npmAuthenticate@0 inputs: - script: "bash ./apiclient/templates/typescript/axios/generate.sh $(System.ArtifactsDirectory) $(Build.BuildNumber)" + workingFile: ./.npmrc + customEndpoint: 'jellyfin-bot for NPM' -# Stable +## Generate npm api client - task: CmdLine@2 displayName: 'Build stable typescript axios client' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') @@ -51,17 +49,6 @@ jobs: workingDir: ./apiclient/generated/typescript/axios ## Publish npm packages -# Unstable - - task: Npm@1 - displayName: 'Publish unstable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') - inputs: - command: publish - publishRegistry: useFeed - publishFeed: 'jellyfin/unstable' - workingDir: ./apiclient/generated/typescript/axios - -# Stable - task: Npm@1 displayName: 'Publish stable typescript axios client' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 67aac45c9d..0dc604a794 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -63,6 +63,7 @@ jobs: sshEndpoint: repository sourceFolder: '$(Build.SourcesDirectory)/deployment/dist' contents: '**' + targetFolder: '/srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)' - job: OpenAPISpec dependsOn: Test @@ -166,7 +167,7 @@ jobs: inputs: sshEndpoint: repository runOptions: 'commands' - commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable & + commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable & - task: SSH@0 displayName: 'Update Stable Repository' @@ -175,7 +176,7 @@ jobs: inputs: sshEndpoint: repository runOptions: 'commands' - commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) & + commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) & - job: PublishNuget displayName: 'Publish NuGet packages' diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..b7a317000b --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +registry=https://registry.npmjs.org/ +@jellyfin:registry=https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/npm/registry/ +always-auth=true \ No newline at end of file diff --git a/Emby.Dlna/ContentDirectory/ControlHandler.cs b/Emby.Dlna/ContentDirectory/ControlHandler.cs index 4b108b89ea..5f25b8cdc0 100644 --- a/Emby.Dlna/ContentDirectory/ControlHandler.cs +++ b/Emby.Dlna/ContentDirectory/ControlHandler.cs @@ -487,7 +487,7 @@ namespace Emby.Dlna.ContentDirectory User = user, Recursive = true, IsMissing = false, - ExcludeItemTypes = new[] { typeof(Book).Name }, + ExcludeItemTypes = new[] { nameof(Book) }, IsFolder = isFolder, MediaTypes = mediaTypes, DtoOptions = GetDtoOptions() @@ -556,7 +556,7 @@ namespace Emby.Dlna.ContentDirectory Limit = limit, StartIndex = startIndex, IsVirtualItem = false, - ExcludeItemTypes = new[] { typeof(Book).Name }, + ExcludeItemTypes = new[] { nameof(Book) }, IsPlaceHolder = false, DtoOptions = GetDtoOptions() }; @@ -575,7 +575,7 @@ namespace Emby.Dlna.ContentDirectory StartIndex = startIndex, Limit = limit, }; - query.IncludeItemTypes = new[] { typeof(LiveTvChannel).Name }; + query.IncludeItemTypes = new[] { nameof(LiveTvChannel) }; SetSorting(query, sort, false); @@ -910,7 +910,7 @@ namespace Emby.Dlna.ContentDirectory query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; var result = _libraryManager.GetItemsResult(query); @@ -923,7 +923,7 @@ namespace Emby.Dlna.ContentDirectory query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; var result = _libraryManager.GetItemsResult(query); @@ -936,7 +936,7 @@ namespace Emby.Dlna.ContentDirectory // query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(BoxSet).Name }; + query.IncludeItemTypes = new[] { nameof(BoxSet) }; var result = _libraryManager.GetItemsResult(query); @@ -949,7 +949,7 @@ namespace Emby.Dlna.ContentDirectory query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(MusicAlbum).Name }; + query.IncludeItemTypes = new[] { nameof(MusicAlbum) }; var result = _libraryManager.GetItemsResult(query); @@ -962,7 +962,7 @@ namespace Emby.Dlna.ContentDirectory query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Audio).Name }; + query.IncludeItemTypes = new[] { nameof(Audio) }; var result = _libraryManager.GetItemsResult(query); @@ -975,7 +975,7 @@ namespace Emby.Dlna.ContentDirectory query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Audio).Name }; + query.IncludeItemTypes = new[] { nameof(Audio) }; var result = _libraryManager.GetItemsResult(query); @@ -988,7 +988,7 @@ namespace Emby.Dlna.ContentDirectory query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; var result = _libraryManager.GetItemsResult(query); @@ -1001,7 +1001,7 @@ namespace Emby.Dlna.ContentDirectory query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; var result = _libraryManager.GetItemsResult(query); @@ -1014,7 +1014,7 @@ namespace Emby.Dlna.ContentDirectory query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; var result = _libraryManager.GetItemsResult(query); @@ -1027,7 +1027,7 @@ namespace Emby.Dlna.ContentDirectory query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(MusicAlbum).Name }; + query.IncludeItemTypes = new[] { nameof(MusicAlbum) }; var result = _libraryManager.GetItemsResult(query); @@ -1181,7 +1181,7 @@ namespace Emby.Dlna.ContentDirectory { UserId = user.Id, Limit = 50, - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, ParentId = parent == null ? Guid.Empty : parent.Id, GroupItems = false }, @@ -1215,7 +1215,7 @@ namespace Emby.Dlna.ContentDirectory Recursive = true, ParentId = parentId, ArtistIds = new[] { item.Id }, - IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + IncludeItemTypes = new[] { nameof(MusicAlbum) }, Limit = limit, StartIndex = startIndex, DtoOptions = GetDtoOptions() @@ -1259,7 +1259,7 @@ namespace Emby.Dlna.ContentDirectory Recursive = true, ParentId = parentId, GenreIds = new[] { item.Id }, - IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + IncludeItemTypes = new[] { nameof(MusicAlbum) }, Limit = limit, StartIndex = startIndex, DtoOptions = GetDtoOptions() @@ -1346,8 +1346,8 @@ namespace Emby.Dlna.ContentDirectory { if (id.StartsWith(name + "_", StringComparison.OrdinalIgnoreCase)) { - stubType = (StubType)Enum.Parse(typeof(StubType), name, true); - id = id.Split(new[] { '_' }, 2)[1]; + stubType = Enum.Parse(name, true); + id = id.Split('_', 2)[1]; break; } diff --git a/Emby.Dlna/Didl/DidlBuilder.cs b/Emby.Dlna/Didl/DidlBuilder.cs index 5b8a89d8f3..abaf522bca 100644 --- a/Emby.Dlna/Didl/DidlBuilder.cs +++ b/Emby.Dlna/Didl/DidlBuilder.cs @@ -123,7 +123,7 @@ namespace Emby.Dlna.Didl { foreach (var att in profile.XmlRootAttributes) { - var parts = att.Name.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries); + var parts = att.Name.Split(':', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 2) { writer.WriteAttributeString(parts[0], parts[1], null, att.Value); diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs index 1807ac6a13..069400833e 100644 --- a/Emby.Dlna/DlnaManager.cs +++ b/Emby.Dlna/DlnaManager.cs @@ -383,9 +383,9 @@ namespace Emby.Dlna continue; } - var filename = Path.GetFileName(name).Substring(namespaceName.Length); - - var path = Path.Combine(systemProfilesPath, filename); + var path = Path.Join( + systemProfilesPath, + Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length)); using (var stream = _assembly.GetManifestResourceStream(name)) { diff --git a/Emby.Dlna/Eventing/DlnaEventManager.cs b/Emby.Dlna/Eventing/DlnaEventManager.cs index 7d8da86ef9..770d56c30f 100644 --- a/Emby.Dlna/Eventing/DlnaEventManager.cs +++ b/Emby.Dlna/Eventing/DlnaEventManager.cs @@ -168,7 +168,7 @@ namespace Emby.Dlna.Eventing builder.Append(""); - using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"), subscription.CallbackUrl); + using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"), subscription.CallbackUrl); options.Content = new StringContent(builder.ToString(), Encoding.UTF8, MediaTypeNames.Text.Xml); options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType); options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange"); diff --git a/Emby.Dlna/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs index 40c2cc0e0a..f8a00efaca 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -257,9 +257,10 @@ namespace Emby.Dlna.Main private async Task RegisterServerEndpoints() { - var addresses = await _appHost.GetLocalIpAddresses(CancellationToken.None).ConfigureAwait(false); + var addresses = await _appHost.GetLocalIpAddresses().ConfigureAwait(false); var udn = CreateUuid(_appHost.SystemId); + var descriptorUri = "/dlna/" + udn + "/description.xml"; foreach (var address in addresses) { @@ -279,7 +280,6 @@ namespace Emby.Dlna.Main _logger.LogInformation("Registering publisher for {0} on {1}", fullService, address); - var descriptorUri = "/dlna/" + udn + "/description.xml"; var uri = new Uri(_appHost.GetLocalApiUrl(address) + descriptorUri); var device = new SsdpRootDevice diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index a5b8e2b3ce..c07c8aefa6 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -326,7 +326,7 @@ namespace Emby.Dlna.PlayTo public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken) { - _logger.LogDebug("{0} - Received PlayRequest: {1}", this._session.DeviceName, command.PlayCommand); + _logger.LogDebug("{0} - Received PlayRequest: {1}", _session.DeviceName, command.PlayCommand); var user = command.ControllingUserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(command.ControllingUserId); @@ -339,7 +339,7 @@ namespace Emby.Dlna.PlayTo var startIndex = command.StartIndex ?? 0; if (startIndex > 0) { - items = items.Skip(startIndex).ToList(); + items = items.GetRange(startIndex, items.Count - startIndex); } var playlist = new List(); diff --git a/Emby.Dlna/Service/BaseControlHandler.cs b/Emby.Dlna/Service/BaseControlHandler.cs index 4b0bbba969..198852ec17 100644 --- a/Emby.Dlna/Service/BaseControlHandler.cs +++ b/Emby.Dlna/Service/BaseControlHandler.cs @@ -169,6 +169,7 @@ namespace Emby.Dlna.Service var result = new ControlRequestInfo(localName, namespaceURI); using var subReader = reader.ReadSubtree(); await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false); + return result; } else { diff --git a/Emby.Naming/Video/CleanDateTimeParser.cs b/Emby.Naming/Video/CleanDateTimeParser.cs index 579c9e91e1..f05d540f8b 100644 --- a/Emby.Naming/Video/CleanDateTimeParser.cs +++ b/Emby.Naming/Video/CleanDateTimeParser.cs @@ -15,6 +15,11 @@ namespace Emby.Naming.Video public static CleanDateTimeResult Clean(string name, IReadOnlyList cleanDateTimeRegexes) { CleanDateTimeResult result = new CleanDateTimeResult(name); + if (string.IsNullOrEmpty(name)) + { + return result; + } + var len = cleanDateTimeRegexes.Count; for (int i = 0; i < len; i++) { diff --git a/Emby.Notifications/NotificationEntryPoint.cs b/Emby.Notifications/NotificationEntryPoint.cs index ded22d26cc..7116d52b11 100644 --- a/Emby.Notifications/NotificationEntryPoint.cs +++ b/Emby.Notifications/NotificationEntryPoint.cs @@ -209,7 +209,10 @@ namespace Emby.Notifications _libraryUpdateTimer = null; } - items = items.Take(10).ToList(); + if (items.Count > 10) + { + items = items.GetRange(0, 10); + } foreach (var item in items) { diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 2adc1d6c34..660bbb2deb 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -62,7 +62,7 @@ namespace Emby.Server.Implementations.AppBase } /// - public string VirtualDataPath { get; } = "%AppDataPath%"; + public string VirtualDataPath => "%AppDataPath%"; /// /// Gets the image cache path. diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 31ca738296..9d5b651d99 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -30,7 +29,6 @@ using Emby.Server.Implementations.Cryptography; using Emby.Server.Implementations.Data; using Emby.Server.Implementations.Devices; using Emby.Server.Implementations.Dto; -using Emby.Server.Implementations.HttpServer; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; @@ -339,7 +337,7 @@ namespace Emby.Server.Implementations /// Gets the email address for use within a comment section of a user agent field. /// Presently used to provide contact information to MusicBrainz service. /// - public string ApplicationUserAgentAddress { get; } = "team@jellyfin.org"; + public string ApplicationUserAgentAddress => "team@jellyfin.org"; /// /// Gets the current application name. @@ -403,7 +401,7 @@ namespace Emby.Server.Implementations /// /// Resolves this instance. /// - /// The type + /// The type. /// ``0. public T Resolve() => ServiceProvider.GetService(); @@ -993,62 +991,36 @@ namespace Emby.Server.Implementations protected abstract void RestartInternal(); - /// - /// Comparison function used in . - /// - /// Item to compare. - /// Item to compare with. - /// Boolean result of the operation. - private static int VersionCompare( - (Version PluginVersion, string Name, string Path) a, - (Version PluginVersion, string Name, string Path) b) + /// + public IEnumerable GetLocalPlugins(string path, bool cleanup = true) { - int compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture); - - if (compare == 0) - { - return a.PluginVersion.CompareTo(b.PluginVersion); - } - - return compare; - } - - /// - /// Returns a list of plugins to install. - /// - /// Path to check. - /// True if an attempt should be made to delete old plugs. - /// Enumerable list of dlls to load. - private IEnumerable GetPlugins(string path, bool cleanup = true) - { - var dllList = new List(); - var versions = new List<(Version PluginVersion, string Name, string Path)>(); + var minimumVersion = new Version(0, 0, 0, 1); + var versions = new List(); var directories = Directory.EnumerateDirectories(path, "*.*", SearchOption.TopDirectoryOnly); - string metafile; foreach (var dir in directories) { try { - metafile = Path.Combine(dir, "meta.json"); + var metafile = Path.Combine(dir, "meta.json"); if (File.Exists(metafile)) { var manifest = _jsonSerializer.DeserializeFromFile(metafile); if (!Version.TryParse(manifest.TargetAbi, out var targetAbi)) { - targetAbi = new Version(0, 0, 0, 1); + targetAbi = minimumVersion; } if (!Version.TryParse(manifest.Version, out var version)) { - version = new Version(0, 0, 0, 1); + version = minimumVersion; } if (ApplicationVersion >= targetAbi) { // Only load Plugins if the plugin is built for this version or below. - versions.Add((version, manifest.Name, dir)); + versions.Add(new LocalPlugin(manifest.Guid, manifest.Name, version, dir)); } } else @@ -1057,15 +1029,15 @@ namespace Emby.Server.Implementations metafile = dir.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)[^1]; int versionIndex = dir.LastIndexOf('_'); - if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version ver)) + if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version parsedVersion)) { // Versioned folder. - versions.Add((ver, metafile, dir)); + versions.Add(new LocalPlugin(Guid.Empty, metafile, parsedVersion, dir)); } else { // Un-versioned folder - Add it under the path name and version 0.0.0.1. - versions.Add((new Version(0, 0, 0, 1), metafile, dir)); + versions.Add(new LocalPlugin(Guid.Empty, metafile, minimumVersion, dir)); } } } @@ -1076,14 +1048,14 @@ namespace Emby.Server.Implementations } string lastName = string.Empty; - versions.Sort(VersionCompare); + versions.Sort(LocalPlugin.Compare); // Traverse backwards through the list. // The first item will be the latest version. for (int x = versions.Count - 1; x >= 0; x--) { if (!string.Equals(lastName, versions[x].Name, StringComparison.OrdinalIgnoreCase)) { - dllList.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories)); + versions[x].DllFiles.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories)); lastName = versions[x].Name; continue; } @@ -1091,6 +1063,7 @@ namespace Emby.Server.Implementations if (!string.IsNullOrEmpty(lastName) && cleanup) { // Attempt a cleanup of old folders. + versions.RemoveAt(x); try { Logger.LogDebug("Deleting {Path}", versions[x].Path); @@ -1103,7 +1076,7 @@ namespace Emby.Server.Implementations } } - return dllList; + return versions; } /// @@ -1114,21 +1087,24 @@ namespace Emby.Server.Implementations { if (Directory.Exists(ApplicationPaths.PluginsPath)) { - foreach (var file in GetPlugins(ApplicationPaths.PluginsPath)) + foreach (var plugin in GetLocalPlugins(ApplicationPaths.PluginsPath)) { - Assembly plugAss; - try + foreach (var file in plugin.DllFiles) { - plugAss = Assembly.LoadFrom(file); - } - catch (FileLoadException ex) - { - Logger.LogError(ex, "Failed to load assembly {Path}", file); - continue; - } + Assembly plugAss; + try + { + plugAss = Assembly.LoadFrom(file); + } + catch (FileLoadException ex) + { + Logger.LogError(ex, "Failed to load assembly {Path}", file); + continue; + } - Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file); - yield return plugAss; + Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file); + yield return plugAss; + } } } diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index fb1bb65a09..19045b72b4 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -250,21 +250,16 @@ namespace Emby.Server.Implementations.Channels var all = channels; var totalCount = all.Count; - if (query.StartIndex.HasValue) + if (query.StartIndex.HasValue || query.Limit.HasValue) { - all = all.Skip(query.StartIndex.Value).ToList(); + int startIndex = query.StartIndex ?? 0; + int count = query.Limit == null ? totalCount - startIndex : Math.Min(query.Limit.Value, totalCount - startIndex); + all = all.GetRange(startIndex, count); } - if (query.Limit.HasValue) - { - all = all.Take(query.Limit.Value).ToList(); - } - - var returnItems = all.ToArray(); - if (query.RefreshLatestChannelItems) { - foreach (var item in returnItems) + foreach (var item in all) { RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult(); } @@ -272,7 +267,7 @@ namespace Emby.Server.Implementations.Channels return new QueryResult { - Items = returnItems, + Items = all, TotalRecordCount = totalCount }; } @@ -543,7 +538,7 @@ namespace Emby.Server.Implementations.Channels return _libraryManager.GetItemIds( new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Channel).Name }, + IncludeItemTypes = new[] { nameof(Channel) }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } }).Select(i => GetChannelFeatures(i.ToString("N", CultureInfo.InvariantCulture))).ToArray(); } diff --git a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs index eeb49b8fef..2391eed428 100644 --- a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs +++ b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs @@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.Channels var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Channel).Name }, + IncludeItemTypes = new[] { nameof(Channel) }, ExcludeItemIds = installedChannelIds.ToArray() }); diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 1c2aeda709..0761b64bd2 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -2403,11 +2403,11 @@ namespace Emby.Server.Implementations.Data if (string.IsNullOrEmpty(item.OfficialRating)) { - builder.Append("((OfficialRating is null) * 10)"); + builder.Append("(OfficialRating is null * 10)"); } else { - builder.Append("((OfficialRating=@ItemOfficialRating) * 10)"); + builder.Append("(OfficialRating=@ItemOfficialRating * 10)"); } if (item.ProductionYear.HasValue) @@ -2416,8 +2416,26 @@ namespace Emby.Server.Implementations.Data builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 5 Then 5 Else 0 End )"); } - //// genres, tags - builder.Append("+ ((Select count(CleanValue) from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId)) * 10)"); + // genres, tags, studios, person, year? + builder.Append("+ (Select count(1) * 10 from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId))"); + + if (item is MusicArtist) + { + // Match albums where the artist is AlbumArtist against other albums. + // It is assumed that similar albums => similar artists. + builder.Append( + @"+ (WITH artistValues AS ( + SELECT DISTINCT albumValues.CleanValue + FROM ItemValues albumValues + INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId + INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = @SimilarItemId + ), similarArtist AS ( + SELECT albumValues.ItemId + FROM ItemValues albumValues + INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId + INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = A.Guid + ) SELECT COUNT(DISTINCT(CleanValue)) * 10 FROM ItemValues WHERE ItemId IN (SELECT ItemId FROM similarArtist) AND CleanValue IN (SELECT CleanValue FROM artistValues))"); + } builder.Append(") as SimilarityScore"); @@ -3914,7 +3932,7 @@ namespace Emby.Server.Implementations.Data if (query.IsPlayed.HasValue) { // We should probably figure this out for all folders, but for right now, this is the only place where we need it - if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], typeof(Series).Name, StringComparison.OrdinalIgnoreCase)) + if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(Series), StringComparison.OrdinalIgnoreCase)) { if (query.IsPlayed.Value) { @@ -4755,29 +4773,29 @@ namespace Emby.Server.Implementations.Data { var list = new List(); - if (IsTypeInQuery(typeof(Person).Name, query)) + if (IsTypeInQuery(nameof(Person), query)) { - list.Add(typeof(Person).Name); + list.Add(nameof(Person)); } - if (IsTypeInQuery(typeof(Genre).Name, query)) + if (IsTypeInQuery(nameof(Genre), query)) { - list.Add(typeof(Genre).Name); + list.Add(nameof(Genre)); } - if (IsTypeInQuery(typeof(MusicGenre).Name, query)) + if (IsTypeInQuery(nameof(MusicGenre), query)) { - list.Add(typeof(MusicGenre).Name); + list.Add(nameof(MusicGenre)); } - if (IsTypeInQuery(typeof(MusicArtist).Name, query)) + if (IsTypeInQuery(nameof(MusicArtist), query)) { - list.Add(typeof(MusicArtist).Name); + list.Add(nameof(MusicArtist)); } - if (IsTypeInQuery(typeof(Studio).Name, query)) + if (IsTypeInQuery(nameof(Studio), query)) { - list.Add(typeof(Studio).Name); + list.Add(nameof(Studio)); } return list; @@ -4832,12 +4850,12 @@ namespace Emby.Server.Implementations.Data var types = new[] { - typeof(Episode).Name, - typeof(Video).Name, - typeof(Movie).Name, - typeof(MusicVideo).Name, - typeof(Series).Name, - typeof(Season).Name + nameof(Episode), + nameof(Video), + nameof(Movie), + nameof(MusicVideo), + nameof(Series), + nameof(Season) }; if (types.Any(i => query.IncludeItemTypes.Contains(i, StringComparer.OrdinalIgnoreCase))) @@ -5002,26 +5020,33 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var commandText = "select Distinct Name from People"; + var commandText = new StringBuilder("select Distinct p.Name from People p"); + + if (query.User != null && query.IsFavorite.HasValue) + { + commandText.Append(" LEFT JOIN TypedBaseItems tbi ON tbi.Name=p.Name AND tbi.Type='"); + commandText.Append(typeof(Person).FullName); + commandText.Append("' LEFT JOIN UserDatas ON tbi.UserDataKey=key AND userId=@UserId"); + } var whereClauses = GetPeopleWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandText.Append(" where ").Append(string.Join(" AND ", whereClauses)); } - commandText += " order by ListOrder"; + commandText.Append(" order by ListOrder"); if (query.Limit > 0) { - commandText += " LIMIT " + query.Limit; + commandText.Append(" LIMIT ").Append(query.Limit); } using (var connection = GetConnection(true)) { var list = new List(); - using (var statement = PrepareStatement(connection, commandText)) + using (var statement = PrepareStatement(connection, commandText.ToString())) { // Run this again to bind the params GetPeopleWhereClauses(query, statement); @@ -5045,7 +5070,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People"; + var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People p"; var whereClauses = GetPeopleWhereClauses(query, null); @@ -5087,19 +5112,13 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (!query.ItemId.Equals(Guid.Empty)) { whereClauses.Add("ItemId=@ItemId"); - if (statement != null) - { - statement.TryBind("@ItemId", query.ItemId.ToByteArray()); - } + statement?.TryBind("@ItemId", query.ItemId.ToByteArray()); } if (!query.AppearsInItemId.Equals(Guid.Empty)) { - whereClauses.Add("Name in (Select Name from People where ItemId=@AppearsInItemId)"); - if (statement != null) - { - statement.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray()); - } + whereClauses.Add("p.Name in (Select Name from People where ItemId=@AppearsInItemId)"); + statement?.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray()); } var queryPersonTypes = query.PersonTypes.Where(IsValidPersonType).ToList(); @@ -5107,10 +5126,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (queryPersonTypes.Count == 1) { whereClauses.Add("PersonType=@PersonType"); - if (statement != null) - { - statement.TryBind("@PersonType", queryPersonTypes[0]); - } + statement?.TryBind("@PersonType", queryPersonTypes[0]); } else if (queryPersonTypes.Count > 1) { @@ -5124,10 +5140,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (queryExcludePersonTypes.Count == 1) { whereClauses.Add("PersonType<>@PersonType"); - if (statement != null) - { - statement.TryBind("@PersonType", queryExcludePersonTypes[0]); - } + statement?.TryBind("@PersonType", queryExcludePersonTypes[0]); } else if (queryExcludePersonTypes.Count > 1) { @@ -5139,19 +5152,24 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (query.MaxListOrder.HasValue) { whereClauses.Add("ListOrder<=@MaxListOrder"); - if (statement != null) - { - statement.TryBind("@MaxListOrder", query.MaxListOrder.Value); - } + statement?.TryBind("@MaxListOrder", query.MaxListOrder.Value); } if (!string.IsNullOrWhiteSpace(query.NameContains)) { - whereClauses.Add("Name like @NameContains"); - if (statement != null) - { - statement.TryBind("@NameContains", "%" + query.NameContains + "%"); - } + whereClauses.Add("p.Name like @NameContains"); + statement?.TryBind("@NameContains", "%" + query.NameContains + "%"); + } + + if (query.IsFavorite.HasValue) + { + whereClauses.Add("isFavorite=@IsFavorite"); + statement?.TryBind("@IsFavorite", query.IsFavorite.Value); + } + + if (query.User != null) + { + statement?.TryBind("@UserId", query.User.InternalId); } return whereClauses; @@ -5420,6 +5438,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type NameStartsWithOrGreater = query.NameStartsWithOrGreater, Tags = query.Tags, OfficialRatings = query.OfficialRatings, + StudioIds = query.StudioIds, GenreIds = query.GenreIds, Genres = query.Genres, Years = query.Years, diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index edb8753fd4..73502c2c9f 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -465,7 +465,7 @@ namespace Emby.Server.Implementations.Dto { var parentAlbumIds = _libraryManager.GetItemIds(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = item.Album, Limit = 1 }); diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 68d981ad1f..df7a034e8f 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; @@ -19,12 +20,12 @@ namespace Emby.Server.Implementations.HttpServer.Security public AuthorizationInfo Authenticate(HttpRequest request) { var auth = _authorizationContext.GetAuthorizationInfo(request); - if (auth?.User == null) + if (!auth.IsAuthenticated) { - return null; + throw new AuthenticationException("Invalid token."); } - if (auth.User.HasPermission(PermissionKind.IsDisabled)) + if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false) { throw new SecurityException("User account has been disabled."); } diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs index 4b407dd9d2..e733c9092a 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs @@ -36,8 +36,7 @@ namespace Emby.Server.Implementations.HttpServer.Security public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext) { var auth = GetAuthorizationDictionary(requestContext); - var (authInfo, _) = - GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); + var authInfo = GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); return authInfo; } @@ -49,19 +48,13 @@ namespace Emby.Server.Implementations.HttpServer.Security private AuthorizationInfo GetAuthorization(HttpContext httpReq) { var auth = GetAuthorizationDictionary(httpReq); - var (authInfo, originalAuthInfo) = - GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query); - - if (originalAuthInfo != null) - { - httpReq.Request.HttpContext.Items["OriginalAuthenticationInfo"] = originalAuthInfo; - } + var authInfo = GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query); httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo; return authInfo; } - private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary( + private AuthorizationInfo GetAuthorizationInfoFromDictionary( in Dictionary auth, in IHeaderDictionary headers, in IQueryCollection queryString) @@ -108,88 +101,102 @@ namespace Emby.Server.Implementations.HttpServer.Security Device = device, DeviceId = deviceId, Version = version, - Token = token + Token = token, + IsAuthenticated = false }; - AuthenticationInfo originalAuthenticationInfo = null; - if (!string.IsNullOrWhiteSpace(token)) + if (string.IsNullOrWhiteSpace(token)) { - var result = _authRepo.Get(new AuthenticationInfoQuery + // Request doesn't contain a token. + return authInfo; + } + + var result = _authRepo.Get(new AuthenticationInfoQuery + { + AccessToken = token + }); + + if (result.Items.Count > 0) + { + authInfo.IsAuthenticated = true; + } + + var originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; + + if (originalAuthenticationInfo != null) + { + var updateToken = false; + + // TODO: Remove these checks for IsNullOrWhiteSpace + if (string.IsNullOrWhiteSpace(authInfo.Client)) { - AccessToken = token - }); + authInfo.Client = originalAuthenticationInfo.AppName; + } - originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; - - if (originalAuthenticationInfo != null) + if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) { - var updateToken = false; + authInfo.DeviceId = originalAuthenticationInfo.DeviceId; + } - // TODO: Remove these checks for IsNullOrWhiteSpace - if (string.IsNullOrWhiteSpace(authInfo.Client)) - { - authInfo.Client = originalAuthenticationInfo.AppName; - } + // Temporary. TODO - allow clients to specify that the token has been shared with a casting device + var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; - if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) + if (string.IsNullOrWhiteSpace(authInfo.Device)) + { + authInfo.Device = originalAuthenticationInfo.DeviceName; + } + else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) { - authInfo.DeviceId = originalAuthenticationInfo.DeviceId; + updateToken = true; + originalAuthenticationInfo.DeviceName = authInfo.Device; } + } - // Temporary. TODO - allow clients to specify that the token has been shared with a casting device - var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; + if (string.IsNullOrWhiteSpace(authInfo.Version)) + { + authInfo.Version = originalAuthenticationInfo.AppVersion; + } + else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) + { + updateToken = true; + originalAuthenticationInfo.AppVersion = authInfo.Version; + } + } - if (string.IsNullOrWhiteSpace(authInfo.Device)) - { - authInfo.Device = originalAuthenticationInfo.DeviceName; - } - else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - originalAuthenticationInfo.DeviceName = authInfo.Device; - } - } + if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) + { + originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; + updateToken = true; + } - if (string.IsNullOrWhiteSpace(authInfo.Version)) - { - authInfo.Version = originalAuthenticationInfo.AppVersion; - } - else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - originalAuthenticationInfo.AppVersion = authInfo.Version; - } - } + if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) + { + authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); - if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) + if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) { - originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; + originalAuthenticationInfo.UserName = authInfo.User.Username; updateToken = true; } - if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) - { - authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); + authInfo.IsApiKey = true; + } + else + { + authInfo.IsApiKey = false; + } - if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) - { - originalAuthenticationInfo.UserName = authInfo.User.Username; - updateToken = true; - } - } - - if (updateToken) - { - _authRepo.Update(originalAuthenticationInfo); - } + if (updateToken) + { + _authRepo.Update(originalAuthenticationInfo); } } - return (authInfo, originalAuthenticationInfo); + return authInfo; } /// @@ -267,7 +274,7 @@ namespace Emby.Server.Implementations.HttpServer.Security if (param.Length == 2) { var value = NormalizeValue(param[1].Trim(new[] { '"' })); - result.Add(param[0], value); + result[param[0]] = value; } } diff --git a/Emby.Server.Implementations/Images/ArtistImageProvider.cs b/Emby.Server.Implementations/Images/ArtistImageProvider.cs index bf57382ed4..afa4ec7b1b 100644 --- a/Emby.Server.Implementations/Images/ArtistImageProvider.cs +++ b/Emby.Server.Implementations/Images/ArtistImageProvider.cs @@ -42,7 +42,7 @@ namespace Emby.Server.Implementations.Images // return _libraryManager.GetItemList(new InternalItemsQuery // { // ArtistIds = new[] { item.Id }, - // IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + // IncludeItemTypes = new[] { nameof(MusicAlbum) }, // OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, // Limit = 4, // Recursive = true, diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs index 1cd4cd66bd..3817882312 100644 --- a/Emby.Server.Implementations/Images/GenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -42,7 +42,12 @@ namespace Emby.Server.Implementations.Images return _libraryManager.GetItemList(new InternalItemsQuery { Genres = new[] { item.Name }, - IncludeItemTypes = new[] { typeof(MusicAlbum).Name, typeof(MusicVideo).Name, typeof(Audio).Name }, + IncludeItemTypes = new[] + { + nameof(MusicAlbum), + nameof(MusicVideo), + nameof(Audio) + }, OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, Limit = 4, Recursive = true, @@ -77,7 +82,7 @@ namespace Emby.Server.Implementations.Images return _libraryManager.GetItemList(new InternalItemsQuery { Genres = new[] { item.Name }, - IncludeItemTypes = new[] { typeof(Series).Name, typeof(Movie).Name }, + IncludeItemTypes = new[] { nameof(Series), nameof(Movie) }, OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, Limit = 4, Recursive = true, diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 00282b71a5..f16eda1ec2 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2440,6 +2440,21 @@ namespace Emby.Server.Implementations.Library new SubtitleResolver(BaseItem.LocalizationManager).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files); } + public BaseItem GetParentItem(string parentId, Guid? userId) + { + if (!string.IsNullOrEmpty(parentId)) + { + return GetItemById(new Guid(parentId)); + } + + if (userId.HasValue && userId != Guid.Empty) + { + return GetUserRootFolder(); + } + + return RootFolder; + } + /// public bool IsVideoFile(string path) { diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index 877fdec86e..658c53f288 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -49,7 +49,7 @@ namespace Emby.Server.Implementations.Library var genres = item .GetRecursiveChildren(user, new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, DtoOptions = dtoOptions }) .Cast private readonly ILogger _logger; private readonly IApplicationPaths _appPaths; + private readonly IEventManager _eventManager; private readonly IHttpClientFactory _httpClientFactory; private readonly IJsonSerializer _jsonSerializer; private readonly IServerConfigurationManager _config; @@ -45,7 +48,7 @@ namespace Emby.Server.Implementations.Updates /// Gets the application host. /// /// The application host. - private readonly IApplicationHost _applicationHost; + private readonly IServerApplicationHost _applicationHost; private readonly IZipClient _zipClient; @@ -63,25 +66,22 @@ namespace Emby.Server.Implementations.Updates public InstallationManager( ILogger logger, - IApplicationHost appHost, + IServerApplicationHost appHost, IApplicationPaths appPaths, + IEventManager eventManager, IHttpClientFactory httpClientFactory, IJsonSerializer jsonSerializer, IServerConfigurationManager config, IFileSystem fileSystem, IZipClient zipClient) { - if (logger == null) - { - throw new ArgumentNullException(nameof(logger)); - } - _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>(); _completedInstallationsInternal = new ConcurrentBag(); _logger = logger; _applicationHost = appHost; _appPaths = appPaths; + _eventManager = eventManager; _httpClientFactory = httpClientFactory; _jsonSerializer = jsonSerializer; _config = config; @@ -89,27 +89,6 @@ namespace Emby.Server.Implementations.Updates _zipClient = zipClient; } - /// - public event EventHandler PackageInstalling; - - /// - public event EventHandler PackageInstallationCompleted; - - /// - public event EventHandler PackageInstallationFailed; - - /// - public event EventHandler PackageInstallationCancelled; - - /// - public event EventHandler PluginUninstalled; - - /// - public event EventHandler PluginUpdated; - - /// - public event EventHandler PluginInstalled; - /// public IEnumerable CompletedInstallations => _completedInstallationsInternal; @@ -237,7 +216,8 @@ namespace Emby.Server.Implementations.Updates private IEnumerable GetAvailablePluginUpdates(IReadOnlyList pluginCatalog) { - foreach (var plugin in _applicationHost.Plugins) + var plugins = _applicationHost.GetLocalPlugins(_appPaths.PluginsPath); + foreach (var plugin in plugins) { var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version); var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version); @@ -268,11 +248,11 @@ namespace Emby.Server.Implementations.Updates var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token; - PackageInstalling?.Invoke(this, package); + await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false); try { - await InstallPackageInternal(package, linkedToken).ConfigureAwait(false); + var isUpdate = await InstallPackageInternal(package, linkedToken).ConfigureAwait(false); lock (_currentInstallationsLock) { @@ -280,8 +260,11 @@ namespace Emby.Server.Implementations.Updates } _completedInstallationsInternal.Add(package); + await _eventManager.PublishAsync(isUpdate + ? (GenericEventArgs)new PluginUpdatedEventArgs(package) + : new PluginInstalledEventArgs(package)).ConfigureAwait(false); - PackageInstallationCompleted?.Invoke(this, package); + _applicationHost.NotifyPendingRestart(); } catch (OperationCanceledException) { @@ -292,7 +275,7 @@ namespace Emby.Server.Implementations.Updates _logger.LogInformation("Package installation cancelled: {0} {1}", package.Name, package.Version); - PackageInstallationCancelled?.Invoke(this, package); + await _eventManager.PublishAsync(new PluginInstallationCancelledEventArgs(package)).ConfigureAwait(false); throw; } @@ -305,11 +288,11 @@ namespace Emby.Server.Implementations.Updates _currentInstallations.Remove(tuple); } - PackageInstallationFailed?.Invoke(this, new InstallationFailedEventArgs + await _eventManager.PublishAsync(new InstallationFailedEventArgs { InstallationInfo = package, Exception = ex - }); + }).ConfigureAwait(false); throw; } @@ -326,7 +309,7 @@ namespace Emby.Server.Implementations.Updates /// The package. /// The cancellation token. /// . - private async Task InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) + private async Task InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) { // Set last update time if we were installed before IPlugin plugin = _applicationHost.Plugins.FirstOrDefault(p => p.Id == package.Guid) @@ -336,20 +319,9 @@ namespace Emby.Server.Implementations.Updates await PerformPackageInstallation(package, cancellationToken).ConfigureAwait(false); // Do plugin-specific processing - if (plugin == null) - { - _logger.LogInformation("New plugin installed: {0} {1}", package.Name, package.Version); + _logger.LogInformation(plugin == null ? "New plugin installed: {0} {1}" : "Plugin updated: {0} {1}", package.Name, package.Version); - PluginInstalled?.Invoke(this, package); - } - else - { - _logger.LogInformation("Plugin updated: {0} {1}", package.Name, package.Version); - - PluginUpdated?.Invoke(this, package); - } - - _applicationHost.NotifyPendingRestart(); + return plugin != null; } private async Task PerformPackageInstallation(InstallationInfo package, CancellationToken cancellationToken) @@ -467,7 +439,7 @@ namespace Emby.Server.Implementations.Updates _config.SaveConfiguration(); } - PluginUninstalled?.Invoke(this, plugin); + _eventManager.Publish(new PluginUninstalledEventArgs(plugin)); _applicationHost.NotifyPendingRestart(); } diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs index d732b6bc6a..7d68aecf99 100644 --- a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs @@ -50,6 +50,13 @@ namespace Jellyfin.Api.Auth bool localAccessOnly = false, bool requiredDownloadPermission = false) { + // ApiKey is currently global admin, always allow. + var isApiKey = ClaimHelpers.GetIsApiKey(claimsPrincipal); + if (isApiKey) + { + return true; + } + // Ensure claim has userId. var userId = ClaimHelpers.GetUserId(claimsPrincipal); if (!userId.HasValue) diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 733c6959eb..27a1f61be0 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -1,10 +1,10 @@ using System.Globalization; -using System.Security.Authentication; using System.Security.Claims; using System.Text.Encodings.Web; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; @@ -43,24 +43,23 @@ namespace Jellyfin.Api.Auth try { var authorizationInfo = _authService.Authenticate(Request); - if (authorizationInfo == null) + var role = UserRoles.User; + if (authorizationInfo.IsApiKey || authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) { - return Task.FromResult(AuthenticateResult.NoResult()); - // TODO return when legacy API is removed. - // Don't spam the log with "Invalid User" - // return Task.FromResult(AuthenticateResult.Fail("Invalid user")); + role = UserRoles.Administrator; } var claims = new[] { - new Claim(ClaimTypes.Name, authorizationInfo.User.Username), - new Claim(ClaimTypes.Role, authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User), + new Claim(ClaimTypes.Name, authorizationInfo.User?.Username ?? string.Empty), + new Claim(ClaimTypes.Role, role), new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)), new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId), new Claim(InternalClaimTypes.Device, authorizationInfo.Device), new Claim(InternalClaimTypes.Client, authorizationInfo.Client), new Claim(InternalClaimTypes.Version, authorizationInfo.Version), new Claim(InternalClaimTypes.Token, authorizationInfo.Token), + new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture)) }; var identity = new ClaimsIdentity(claims, Scheme.Name); diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs index b5913daab9..be77b7a4e4 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement) { var validated = ValidateClaims(context.User); - if (!validated) + if (validated) + { + context.Succeed(requirement); + } + else { context.Fail(); - return Task.CompletedTask; } - context.Succeed(requirement); return Task.CompletedTask; } } diff --git a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs index 5213bc4cb7..a7623556a9 100644 --- a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs +++ b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs @@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement) { var validated = ValidateClaims(context.User, ignoreSchedule: true); - if (!validated) + if (validated) + { + context.Succeed(requirement); + } + else { context.Fail(); - return Task.CompletedTask; } - context.Succeed(requirement); return Task.CompletedTask; } } diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs index af73352bcc..d772ec5542 100644 --- a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs +++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs @@ -29,13 +29,13 @@ namespace Jellyfin.Api.Auth.LocalAccessPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement) { var validated = ValidateClaims(context.User, localAccessOnly: true); - if (!validated) + if (validated) { - context.Fail(); + context.Succeed(requirement); } else { - context.Succeed(requirement); + context.Fail(); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs index 4d7c7135d5..8323312e51 100644 --- a/Jellyfin.Api/Constants/InternalClaimTypes.cs +++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs @@ -34,5 +34,10 @@ /// Token. /// public const string Token = "Jellyfin-Token"; + + /// + /// Is Api Key. + /// + public const string IsApiKey = "Jellyfin-IsApiKey"; } } diff --git a/Jellyfin.Api/Controllers/AlbumsController.cs b/Jellyfin.Api/Controllers/AlbumsController.cs deleted file mode 100644 index 357f646a2b..0000000000 --- a/Jellyfin.Api/Controllers/AlbumsController.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Jellyfin.Api.Controllers -{ - /// - /// The albums controller. - /// - [Route("")] - public class AlbumsController : BaseJellyfinApiController - { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public AlbumsController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - } - - /// - /// Finds albums similar to a given album. - /// - /// The album id. - /// Optional. Filter by user id, and attach user data. - /// Optional. Ids of artists to exclude. - /// Optional. The maximum number of records to return. - /// Similar albums returned. - /// A with similar albums. - [HttpGet("Albums/{albumId}/Similar")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetSimilarAlbums( - [FromRoute, Required] string albumId, - [FromQuery] Guid? userId, - [FromQuery] string? excludeArtistIds, - [FromQuery] int? limit) - { - var dtoOptions = new DtoOptions().AddClientFields(Request); - - return SimilarItemsHelper.GetSimilarItemsResult( - dtoOptions, - _userManager, - _libraryManager, - _dtoService, - userId, - albumId, - excludeArtistIds, - limit, - new[] { typeof(MusicAlbum) }, - GetAlbumSimilarityScore); - } - - /// - /// Finds artists similar to a given artist. - /// - /// The artist id. - /// Optional. Filter by user id, and attach user data. - /// Optional. Ids of artists to exclude. - /// Optional. The maximum number of records to return. - /// Similar artists returned. - /// A with similar artists. - [HttpGet("Artists/{artistId}/Similar")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetSimilarArtists( - [FromRoute, Required] string artistId, - [FromQuery] Guid? userId, - [FromQuery] string? excludeArtistIds, - [FromQuery] int? limit) - { - var dtoOptions = new DtoOptions().AddClientFields(Request); - - return SimilarItemsHelper.GetSimilarItemsResult( - dtoOptions, - _userManager, - _libraryManager, - _dtoService, - userId, - artistId, - excludeArtistIds, - limit, - new[] { typeof(MusicArtist) }, - SimilarItemsHelper.GetSimiliarityScore); - } - - /// - /// Gets a similairty score of two albums. - /// - /// The first item. - /// The item1 people. - /// All people. - /// The second item. - /// System.Int32. - private int GetAlbumSimilarityScore(BaseItem item1, List item1People, List allPeople, BaseItem item2) - { - var points = SimilarItemsHelper.GetSimiliarityScore(item1, item1People, allPeople, item2); - - var album1 = (MusicAlbum)item1; - var album2 = (MusicAlbum)item2; - - var artists1 = album1 - .GetAllArtists() - .DistinctNames() - .ToList(); - - var artists2 = new HashSet( - album2.GetAllArtists().DistinctNames(), - StringComparer.OrdinalIgnoreCase); - - return points + artists1.Where(artists2.Contains).Sum(i => 5); - } - } -} diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index 784ecaeb32..9bad206e02 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -4,11 +4,13 @@ using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -51,7 +53,7 @@ namespace Jellyfin.Api.Controllers /// Optional. The maximum number of records to return. /// Optional. Search term. /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. /// Optional. Specify additional filters to apply. @@ -86,10 +88,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? excludeItemTypes, [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, [FromQuery] string? mediaTypes, [FromQuery] string? genres, @@ -99,7 +101,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, [FromQuery] string? personIds, [FromQuery] string? personTypes, @@ -112,8 +114,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -145,9 +146,9 @@ namespace Jellyfin.Api.Controllers NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, ',', true), - OfficialRatings = RequestHelpers.Split(officialRatings, ',', true), - Genres = RequestHelpers.Split(genres, ',', true), + Tags = RequestHelpers.Split(tags, '|', true), + OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), + Genres = RequestHelpers.Split(genres, '|', true), GenreIds = RequestHelpers.GetGuids(genreIds), StudioIds = RequestHelpers.GetGuids(studioIds), Person = person, @@ -260,7 +261,7 @@ namespace Jellyfin.Api.Controllers /// Optional. The maximum number of records to return. /// Optional. Search term. /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. /// Optional. Specify additional filters to apply. @@ -295,10 +296,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? excludeItemTypes, [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, [FromQuery] string? mediaTypes, [FromQuery] string? genres, @@ -308,7 +309,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, [FromQuery] string? personIds, [FromQuery] string? personTypes, @@ -321,8 +322,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -354,9 +354,9 @@ namespace Jellyfin.Api.Controllers NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, ',', true), - OfficialRatings = RequestHelpers.Split(officialRatings, ',', true), - Genres = RequestHelpers.Split(genres, ',', true), + Tags = RequestHelpers.Split(tags, '|', true), + OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), + Genres = RequestHelpers.Split(genres, '|', true), GenreIds = RequestHelpers.GetGuids(genreIds), StudioIds = RequestHelpers.GetGuids(studioIds), Person = person, diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index fda00b8d46..ec9d7cdce1 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -107,7 +108,7 @@ namespace Jellyfin.Api.Controllers /// Optional. Sort Order - Ascending,Descending. /// Optional. Specify additional filters to apply. /// Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Channel items returned. /// /// A representing the request to get the channel items. @@ -121,9 +122,9 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? sortOrder, - [FromQuery] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] string? sortBy, - [FromQuery] string? fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) @@ -136,8 +137,7 @@ namespace Jellyfin.Api.Controllers ChannelIds = new[] { channelId }, ParentId = folderId ?? Guid.Empty, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - DtoOptions = new DtoOptions() - .AddItemFields(fields) + DtoOptions = new DtoOptions { Fields = fields } }; foreach (var filter in filters) @@ -184,7 +184,7 @@ namespace Jellyfin.Api.Controllers /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. /// Optional. Specify additional filters to apply. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Specify one or more channel id's, comma delimited. /// Latest channel items returned. /// @@ -196,8 +196,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] ItemFilter[] filters, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? channelIds) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -213,8 +213,7 @@ namespace Jellyfin.Api.Controllers .Where(i => !string.IsNullOrWhiteSpace(i)) .Select(i => new Guid(i)) .ToArray(), - DtoOptions = new DtoOptions() - .AddItemFields(fields) + DtoOptions = new DtoOptions { Fields = fields } }; foreach (var filter in filters) diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index 2fc697a6aa..eae06b767c 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -83,14 +83,14 @@ namespace Jellyfin.Api.Controllers /// Adds items to a collection. /// /// The collection id. - /// Item ids, comma delimited. + /// Item ids, comma delimited. /// Items added to collection. /// A indicating success. [HttpPost("{collectionId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string itemIds) + public async Task AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids) { - await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(itemIds)).ConfigureAwait(true); + await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(true); return NoContent(); } @@ -98,14 +98,14 @@ namespace Jellyfin.Api.Controllers /// Removes items from a collection. /// /// The collection id. - /// Item ids, comma delimited. + /// Item ids, comma delimited. /// Items removed from collection. /// A indicating success. [HttpDelete("{collectionId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string itemIds) + public async Task RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids) { - await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(itemIds)).ConfigureAwait(false); + await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(false); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index 74380c2eff..b3e3490c2a 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Api.Controllers /// /// Devices Controller. /// - [Authorize(Policy = Policies.DefaultAuthorization)] + [Authorize(Policy = Policies.RequiresElevation)] public class DevicesController : BaseJellyfinApiController { private readonly IDeviceManager _deviceManager; @@ -46,7 +46,6 @@ namespace Jellyfin.Api.Controllers /// Devices retrieved. /// An containing the list of devices. [HttpGet] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) { @@ -62,7 +61,6 @@ namespace Jellyfin.Api.Controllers /// Device not found. /// An containing the device info on success, or a if the device could not be found. [HttpGet("Info")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult GetDeviceInfo([FromQuery, Required] string id) @@ -84,7 +82,6 @@ namespace Jellyfin.Api.Controllers /// Device not found. /// An containing the device info on success, or a if the device could not be found. [HttpGet("Options")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult GetDeviceOptions([FromQuery, Required] string id) @@ -107,7 +104,6 @@ namespace Jellyfin.Api.Controllers /// Device not found. /// A on success, or a if the device could not be found. [HttpPost("Options")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateDeviceOptions( diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 874467c75b..76f5717e30 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -81,6 +81,9 @@ namespace Jellyfin.Api.Controllers dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture); dto.CustomPrefs["tvhome"] = displayPreferences.TvHome; + // This will essentially be a noop if no changes have been made, but new prefs must be saved at least. + _displayPreferencesManager.SaveChanges(); + return dto; } diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 271ae293b4..4e6455eaa7 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -77,6 +77,7 @@ namespace Jellyfin.Api.Controllers /// Gets Dlna media receiver registrar xml. /// /// Server UUID. + /// Dlna media receiver registrar xml returned. /// Dlna media receiver registrar xml. [HttpGet("{serverId}/MediaReceiverRegistrar")] [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")] @@ -94,6 +95,7 @@ namespace Jellyfin.Api.Controllers /// Gets Dlna media receiver registrar xml. /// /// Server UUID. + /// Dlna media receiver registrar xml returned. /// Dlna media receiver registrar xml. [HttpGet("{serverId}/ConnectionManager")] [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")] @@ -111,8 +113,12 @@ namespace Jellyfin.Api.Controllers /// Process a content directory control request. /// /// Server UUID. + /// Request processed. /// Control response. [HttpPost("{serverId}/ContentDirectory/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public async Task> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) { return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); @@ -122,8 +128,12 @@ namespace Jellyfin.Api.Controllers /// Process a connection manager control request. /// /// Server UUID. + /// Request processed. /// Control response. [HttpPost("{serverId}/ConnectionManager/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public async Task> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) { return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); @@ -133,8 +143,12 @@ namespace Jellyfin.Api.Controllers /// Process a media receiver registrar control request. /// /// Server UUID. + /// Request processed. /// Control response. [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public async Task> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) { return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); @@ -144,11 +158,15 @@ namespace Jellyfin.Api.Controllers /// Processes an event subscription request. /// /// Server UUID. + /// Request processed. /// Event subscription response. [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")] [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult ProcessMediaReceiverRegistrarEventRequest(string serverId) { return ProcessEventRequest(_mediaReceiverRegistrar); @@ -158,11 +176,15 @@ namespace Jellyfin.Api.Controllers /// Processes an event subscription request. /// /// Server UUID. + /// Request processed. /// Event subscription response. [HttpSubscribe("{serverId}/ContentDirectory/Events")] [HttpUnsubscribe("{serverId}/ContentDirectory/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult ProcessContentDirectoryEventRequest(string serverId) { return ProcessEventRequest(_contentDirectory); @@ -172,11 +194,15 @@ namespace Jellyfin.Api.Controllers /// Processes an event subscription request. /// /// Server UUID. + /// Request processed. /// Event subscription response. [HttpSubscribe("{serverId}/ConnectionManager/Events")] [HttpUnsubscribe("{serverId}/ConnectionManager/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult ProcessConnectionManagerEventRequest(string serverId) { return ProcessEventRequest(_connectionManager); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 1153a601e7..e07690e110 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -295,6 +295,7 @@ namespace Jellyfin.Api.Controllers /// Optional. Whether to break on non key frames. /// Optional. Specify a specific audio sample rate, e.g. 44100. /// Optional. The maximum audio bit depth. + /// Optional. The maximum streaming bitrate. /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. @@ -351,6 +352,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, @@ -403,7 +405,7 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = audioChannels, Profile = profile, @@ -623,6 +625,7 @@ namespace Jellyfin.Api.Controllers /// Optional. Whether to break on non key frames. /// Optional. Specify a specific audio sample rate, e.g. 44100. /// Optional. The maximum audio bit depth. + /// Optional. The maximum streaming bitrate. /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. @@ -677,6 +680,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, @@ -729,7 +733,7 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = audioChannels, Profile = profile, @@ -959,6 +963,7 @@ namespace Jellyfin.Api.Controllers /// Optional. Whether to break on non key frames. /// Optional. Specify a specific audio sample rate, e.g. 44100. /// Optional. The maximum audio bit depth. + /// Optional. The maximum streaming bitrate. /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. @@ -1017,6 +1022,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, @@ -1069,7 +1075,7 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = audioChannels, Profile = profile, diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index f4b69b312e..89f4beefd4 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -1,15 +1,16 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -47,30 +48,16 @@ namespace Jellyfin.Api.Controllers /// /// Gets all genres from a given item, folder, or the entire library. /// - /// Optional filter by minimum community rating. /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. /// The search term. /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. /// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited. - /// Optional. Specify additional filters to apply. /// Optional filter by items that are marked as favorite, or not. - /// Optional filter by MediaType. Allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. - /// Optional, include user data. /// Optional, the max number of images to return, per image type. /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered to include only those containing the specified person. - /// Optional. If specified, results will be filtered to include only those containing the specified person id. - /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. /// User id. /// Optional filter by items whose name is sorted equally or greater than a given input string. /// Optional filter by items whose name is sorted equally than a given input string. @@ -82,30 +69,16 @@ namespace Jellyfin.Api.Controllers [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetGenres( - [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? excludeItemTypes, [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, - [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, @@ -113,45 +86,24 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = null; - BaseItem parentItem; + User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null; - if (userId.HasValue && !userId.Equals(Guid.Empty)) - { - user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } + var parentItem = _libraryManager.GetParentItem(parentId, userId); var query = new InternalItemsQuery(user) { ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(), - MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, EnableTotalRecordCount = enableTotalRecordCount @@ -169,87 +121,20 @@ namespace Jellyfin.Api.Controllers } } - // Studios - if (!string.IsNullOrEmpty(studios)) + QueryResult<(BaseItem, ItemCounts)> result; + if (parentItem is ICollectionFolder parentCollectionFolder + && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal) + || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal))) { - query.StudioIds = studios.Split('|') - .Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null) - .Select(i => i!.Id) - .ToArray(); + result = _libraryManager.GetMusicGenres(query); + } + else + { + result = _libraryManager.GetGenres(query); } - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } - - var result = new QueryResult<(BaseItem, ItemCounts)>(); - - var dtos = result.Items.Select(i => - { - var (baseItem, counts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; - }); - - return new QueryResult - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; + var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } /// diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 05efe23553..4a67c1aede 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -109,7 +109,7 @@ namespace Jellyfin.Api.Controllers var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); if (user.ProfileImage != null) { - _userManager.ClearProfileImage(user); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType))); @@ -138,7 +138,7 @@ namespace Jellyfin.Api.Controllers [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public ActionResult DeleteUserImage( + public async Task DeleteUserImage( [FromRoute, Required] Guid userId, [FromRoute, Required] ImageType imageType, [FromRoute] int? index = null) @@ -158,7 +158,7 @@ namespace Jellyfin.Api.Controllers _logger.LogError(e, "Error deleting user profile image:"); } - _userManager.ClearProfileImage(user); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 07fed97642..d17a26db43 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -4,12 +4,14 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -54,7 +56,7 @@ namespace Jellyfin.Api.Controllers /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include image information in output. /// Optional. Include user data. /// Optional. The max number of images to return, per image type. @@ -67,18 +69,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -91,7 +92,7 @@ namespace Jellyfin.Api.Controllers /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include image information in output. /// Optional. Include user data. /// Optional. The max number of images to return, per image type. @@ -104,18 +105,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var album = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); @@ -128,7 +128,7 @@ namespace Jellyfin.Api.Controllers /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include image information in output. /// Optional. Include user data. /// Optional. The max number of images to return, per image type. @@ -141,18 +141,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var playlist = (Playlist)_libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); @@ -165,7 +164,7 @@ namespace Jellyfin.Api.Controllers /// The genre name. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include image information in output. /// Optional. Include user data. /// Optional. The max number of images to return, per image type. @@ -178,17 +177,16 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] string name, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); @@ -201,7 +199,7 @@ namespace Jellyfin.Api.Controllers /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include image information in output. /// Optional. Include user data. /// Optional. The max number of images to return, per image type. @@ -214,18 +212,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -238,7 +235,7 @@ namespace Jellyfin.Api.Controllers /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include image information in output. /// Optional. Include user data. /// Optional. The max number of images to return, per image type. @@ -251,18 +248,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -275,7 +271,7 @@ namespace Jellyfin.Api.Controllers /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include image information in output. /// Optional. Include user data. /// Optional. The max number of images to return, per image type. @@ -288,18 +284,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -315,9 +310,9 @@ namespace Jellyfin.Api.Controllers TotalRecordCount = list.Count }; - if (limit.HasValue) + if (limit.HasValue && limit < list.Count) { - list = list.Take(limit.Value).ToList(); + list = list.GetRange(0, limit.Value); } var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index b50b1e836b..d8d371ebc3 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -5,6 +5,7 @@ using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -159,7 +160,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isHd, [FromQuery] bool? is4K, [FromQuery] string? locationTypes, - [FromQuery] LocationType[] excludeLocationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, [FromQuery] double? minCommunityRating, @@ -179,13 +180,13 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? searchTerm, [FromQuery] string? sortOrder, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? excludeItemTypes, [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, [FromQuery] string? mediaTypes, - [FromQuery] string? imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, [FromQuery] string? sortBy, [FromQuery] bool? isPlayed, [FromQuery] string? genres, @@ -194,7 +195,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, [FromQuery] string? personIds, [FromQuery] string? personTypes, @@ -233,8 +234,7 @@ namespace Jellyfin.Api.Controllers var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -342,7 +342,7 @@ namespace Jellyfin.Api.Controllers PersonIds = RequestHelpers.GetGuids(personIds), PersonTypes = RequestHelpers.Split(personTypes, ',', true), Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), - ImageTypes = RequestHelpers.Split(imageTypes, ',', true).Select(v => Enum.Parse(v, true)).ToArray(), + ImageTypes = imageTypes, VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse(v, true)).ToArray(), AdjacentTo = adjacentTo, ItemIds = RequestHelpers.GetGuids(ids), @@ -532,11 +532,11 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? mediaTypes, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? excludeItemTypes, [FromQuery] string? includeItemTypes, [FromQuery] bool enableTotalRecordCount = true, @@ -544,8 +544,7 @@ namespace Jellyfin.Api.Controllers { var user = _userManager.GetUserById(userId); var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 8a872ae133..0d5c1d2782 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -12,6 +12,7 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryDtos; using Jellyfin.Data.Entities; using MediaBrowser.Common.Progress; @@ -680,12 +681,12 @@ namespace Jellyfin.Api.Controllers /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. /// Similar items returned. /// A containing the similar items. - [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists2")] + [HttpGet("Artists/{itemId}/Similar")] [HttpGet("Items/{itemId}/Similar")] - [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums2")] - [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows2")] - [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies2")] - [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers2")] + [HttpGet("Albums/{itemId}/Similar")] + [HttpGet("Shows/{itemId}/Similar")] + [HttpGet("Movies/{itemId}/Similar")] + [HttpGet("Trailers/{itemId}/Similar")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetSimilarItems( @@ -693,7 +694,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? excludeArtistIds, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) { var item = itemId.Equals(Guid.Empty) ? (!userId.Equals(Guid.Empty) @@ -701,33 +702,71 @@ namespace Jellyfin.Api.Controllers : _libraryManager.RootFolder) : _libraryManager.GetItemById(itemId); - var program = item as IHasProgramAttributes; - var isMovie = item is MediaBrowser.Controller.Entities.Movies.Movie || (program != null && program.IsMovie) || item is Trailer; - if (program != null && program.IsSeries) - { - return GetSimilarItemsResult( - item, - excludeArtistIds, - userId, - limit, - fields, - new[] { nameof(Series) }, - false); - } - - if (item is MediaBrowser.Controller.Entities.TV.Episode || (item is IItemByName && !(item is MusicArtist))) + if (item is Episode || (item is IItemByName && !(item is MusicArtist))) { return new QueryResult(); } - return GetSimilarItemsResult( - item, - excludeArtistIds, - userId, - limit, - fields, - new[] { item.GetType().Name }, - isMovie); + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(Request); + + var program = item as IHasProgramAttributes; + bool? isMovie = item is Movie || (program != null && program.IsMovie) || item is Trailer; + bool? isSeries = item is Series || (program != null && program.IsSeries); + + var includeItemTypes = new List(); + if (isMovie.Value) + { + includeItemTypes.Add(nameof(Movie)); + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + includeItemTypes.Add(nameof(Trailer)); + includeItemTypes.Add(nameof(LiveTvProgram)); + } + } + else if (isSeries.Value) + { + includeItemTypes.Add(nameof(Series)); + } + else + { + // For non series and movie types these columns are typically null + isSeries = null; + isMovie = null; + includeItemTypes.Add(item.GetType().Name); + } + + var query = new InternalItemsQuery(user) + { + Limit = limit, + IncludeItemTypes = includeItemTypes.ToArray(), + IsMovie = isMovie, + IsSeries = isSeries, + SimilarTo = item, + DtoOptions = dtoOptions, + EnableTotalRecordCount = !isMovie ?? true, + EnableGroupByMetadataKey = isMovie ?? false, + MinSimilarityScore = 2 // A remnant from album/artist scoring + }; + + // ExcludeArtistIds + if (!string.IsNullOrEmpty(excludeArtistIds)) + { + query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); + } + + List itemsResult = _libraryManager.GetItemList(query); + + var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); + + return new QueryResult + { + Items = returnList, + TotalRecordCount = itemsResult.Count + }; } /// @@ -880,75 +919,6 @@ namespace Jellyfin.Api.Controllers } } - private QueryResult GetSimilarItemsResult( - BaseItem item, - string? excludeArtistIds, - Guid? userId, - int? limit, - string? fields, - string[] includeItemTypes, - bool isMovie) - { - var user = userId.HasValue && !userId.Equals(Guid.Empty) - ? _userManager.GetUserById(userId.Value) - : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) - .AddClientFields(Request); - - var query = new InternalItemsQuery(user) - { - Limit = limit, - IncludeItemTypes = includeItemTypes, - IsMovie = isMovie, - SimilarTo = item, - DtoOptions = dtoOptions, - EnableTotalRecordCount = !isMovie, - EnableGroupByMetadataKey = isMovie - }; - - // ExcludeArtistIds - if (!string.IsNullOrEmpty(excludeArtistIds)) - { - query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); - } - - List itemsResult; - - if (isMovie) - { - var itemTypes = new List { nameof(MediaBrowser.Controller.Entities.Movies.Movie) }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(nameof(Trailer)); - itemTypes.Add(nameof(LiveTvProgram)); - } - - query.IncludeItemTypes = itemTypes.ToArray(); - itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList(); - } - else if (item is MusicArtist) - { - query.IncludeItemTypes = Array.Empty(); - - itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList(); - } - else - { - itemsResult = _libraryManager.GetItemList(query); - } - - var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); - - var result = new QueryResult - { - Items = returnList, - TotalRecordCount = itemsResult.Count - }; - - return result; - } - private static string[] GetRepresentativeItemTypes(string? contentType) { return contentType switch diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index d290e3c5bd..94995650cb 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; @@ -75,7 +76,7 @@ namespace Jellyfin.Api.Controllers public async Task AddVirtualFolder( [FromQuery] string? name, [FromQuery] string? collectionType, - [FromQuery] string[] paths, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, [FromBody] AddVirtualFolderDto? libraryOptionsDto, [FromQuery] bool refreshLibrary = false) { diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 3557e63047..458f40b2da 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -14,6 +14,7 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LiveTvDtos; using Jellyfin.Data.Enums; using MediaBrowser.Common; @@ -26,6 +27,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Net; using MediaBrowser.Model.Querying; @@ -117,7 +119,7 @@ namespace Jellyfin.Api.Controllers /// Optional. Include image information in output. /// Optional. The max number of images to return, per image type. /// "Optional. The image types to include in the output. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include user data. /// Optional. Key to sort by. /// Optional. Sort order. @@ -145,16 +147,15 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isDisliked, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder, [FromQuery] bool enableFavoriteSorting = false, [FromQuery] bool addCurrentProgram = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -238,7 +239,7 @@ namespace Jellyfin.Api.Controllers /// Optional. Include image information in output. /// Optional. The max number of images to return, per image type. /// Optional. The image types to include in the output. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include user data. /// Optional. Filter for movies. /// Optional. Filter for series. @@ -262,8 +263,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? seriesTimerId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool? isMovie, [FromQuery] bool? isSeries, @@ -273,8 +274,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isLibraryItem, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -295,7 +295,7 @@ namespace Jellyfin.Api.Controllers IsKids = isKids, IsSports = isSports, IsLibraryItem = isLibraryItem, - Fields = RequestHelpers.GetItemFields(fields), + Fields = fields, ImageTypeLimit = imageTypeLimit, EnableImages = enableImages }, dtoOptions); @@ -315,7 +315,7 @@ namespace Jellyfin.Api.Controllers /// Optional. Include image information in output. /// Optional. The max number of images to return, per image type. /// Optional. The image types to include in the output. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include user data. /// Optional. Return total record count. /// Live tv recordings returned. @@ -349,8 +349,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? seriesTimerId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) { @@ -529,7 +529,7 @@ namespace Jellyfin.Api.Controllers /// Optional. Include user data. /// Optional. Filter by series timer id. /// Optional. Filter by library series id. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Retrieve total record count. /// Live tv epgs returned. /// @@ -560,11 +560,11 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? genreIds, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] string? seriesTimerId, [FromQuery] Guid? librarySeriesId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool enableTotalRecordCount = true) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -591,7 +591,7 @@ namespace Jellyfin.Api.Controllers IsKids = isKids, IsSports = isSports, SeriesTimerId = seriesTimerId, - Genres = RequestHelpers.Split(genres, ',', true), + Genres = RequestHelpers.Split(genres, '|', true), GenreIds = RequestHelpers.GetGuids(genreIds) }; @@ -605,8 +605,7 @@ namespace Jellyfin.Api.Controllers } } - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); @@ -647,7 +646,7 @@ namespace Jellyfin.Api.Controllers IsKids = body.IsKids, IsSports = body.IsSports, SeriesTimerId = body.SeriesTimerId, - Genres = RequestHelpers.Split(body.Genres, ',', true), + Genres = RequestHelpers.Split(body.Genres, '|', true), GenreIds = RequestHelpers.GetGuids(body.GenreIds) }; @@ -661,8 +660,7 @@ namespace Jellyfin.Api.Controllers } } - var dtoOptions = new DtoOptions() - .AddItemFields(body.Fields) + var dtoOptions = new DtoOptions { Fields = body.Fields } .AddClientFields(Request) .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes); return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); @@ -684,7 +682,7 @@ namespace Jellyfin.Api.Controllers /// Optional. The max number of images to return, per image type. /// Optional. The image types to include in the output. /// The genres to return guide information for. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. include user data. /// Retrieve total record count. /// Recommended epgs returned. @@ -704,9 +702,9 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isSports, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? genreIds, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) { @@ -728,8 +726,7 @@ namespace Jellyfin.Api.Controllers GenreIds = RequestHelpers.GetGuids(genreIds) }; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); return _liveTvManager.GetRecommendedPrograms(query, dtoOptions, CancellationToken.None); @@ -1219,11 +1216,8 @@ namespace Jellyfin.Api.Controllers return NotFound(); } - await using var memoryStream = new MemoryStream(); - await new ProgressiveFileCopier(liveStreamInfo, null, _transcodingJobHelper, CancellationToken.None) - .WriteToAsync(memoryStream, CancellationToken.None) - .ConfigureAwait(false); - return File(memoryStream, MimeTypes.GetMimeType("file." + container)); + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetFilePath(), null, _transcodingJobHelper); + return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container)); } private void AssertUserCanManageLiveTv() diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 4c21999b1b..186024585b 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -104,7 +104,7 @@ namespace Jellyfin.Api.Controllers public async Task> GetPostedPlaybackInfo( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, - [FromQuery] long? maxStreamingBitrate, + [FromQuery] int? maxStreamingBitrate, [FromQuery] long? startTimeTicks, [FromQuery] int? audioStreamIndex, [FromQuery] int? subtitleStreamIndex, @@ -234,7 +234,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? openToken, [FromQuery] Guid? userId, [FromQuery] string? playSessionId, - [FromQuery] long? maxStreamingBitrate, + [FromQuery] int? maxStreamingBitrate, [FromQuery] long? startTimeTicks, [FromQuery] int? audioStreamIndex, [FromQuery] int? subtitleStreamIndex, diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 7fcfc749de..ebc148fe56 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; @@ -65,15 +66,14 @@ namespace Jellyfin.Api.Controllers public ActionResult> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, [FromQuery] int itemLimit = 8) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request); var categories = new List(); @@ -85,8 +85,8 @@ namespace Jellyfin.Api.Controllers IncludeItemTypes = new[] { nameof(Movie), - // typeof(Trailer).Name, - // typeof(LiveTvProgram).Name + // nameof(Trailer), + // nameof(LiveTvProgram) }, // IsMovie = true OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple(i, SortOrder.Descending)).ToArray(), diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index d216b7f728..ab9e4cf8e6 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -1,16 +1,17 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -47,30 +48,16 @@ namespace Jellyfin.Api.Controllers /// /// Gets all music genres from a given item, folder, or the entire library. /// - /// Optional filter by minimum community rating. /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. /// The search term. /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. /// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited. - /// Optional. Specify additional filters to apply. /// Optional filter by items that are marked as favorite, or not. - /// Optional filter by MediaType. Allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. - /// Optional, include user data. /// Optional, the max number of images to return, per image type. /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered to include only those containing the specified person. - /// Optional. If specified, results will be filtered to include only those containing the specified person id. - /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. /// User id. /// Optional filter by items whose name is sorted equally or greater than a given input string. /// Optional filter by items whose name is sorted equally than a given input string. @@ -80,31 +67,18 @@ namespace Jellyfin.Api.Controllers /// Music genres returned. /// An containing the queryresult of music genres. [HttpGet] + [Obsolete("Use GetGenres instead")] public ActionResult> GetMusicGenres( - [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? excludeItemTypes, [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, - [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, @@ -112,45 +86,24 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = null; - BaseItem parentItem; + User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null; - if (userId.HasValue && !userId.Equals(Guid.Empty)) - { - user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } + var parentItem = _libraryManager.GetParentItem(parentId, userId); var query = new InternalItemsQuery(user) { ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(), - MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, EnableTotalRecordCount = enableTotalRecordCount @@ -168,87 +121,10 @@ namespace Jellyfin.Api.Controllers } } - // Studios - if (!string.IsNullOrEmpty(studios)) - { - query.StudioIds = studios.Split('|') - .Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null) - .Select(i => i!.Id) - .ToArray(); - } - - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } - var result = _libraryManager.GetMusicGenres(query); - var dtos = result.Items.Select(i => - { - var (baseItem, counts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; - }); - - return new QueryResult - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; + var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } /// diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index bbef1ff9a1..6ac3e6417a 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -1,15 +1,16 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -26,6 +27,7 @@ namespace Jellyfin.Api.Controllers private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; /// /// Initializes a new instance of the class. @@ -33,221 +35,81 @@ namespace Jellyfin.Api.Controllers /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public PersonsController( ILibraryManager libraryManager, IDtoService dtoService, - IUserManager userManager) + IUserManager userManager, + IUserDataManager userDataManager) { _libraryManager = libraryManager; _dtoService = dtoService; _userManager = userManager; + _userDataManager = userDataManager; } /// - /// Gets all persons from a given item, folder, or the entire library. + /// Gets all persons. /// - /// Optional filter by minimum community rating. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. /// The search term. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. - /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited. - /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes. - /// Optional filter by items that are marked as favorite, or not. - /// Optional filter by MediaType. Allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Specify additional filters to apply. + /// Optional filter by items that are marked as favorite, or not. userId is required. /// Optional, include user data. /// Optional, the max number of images to return, per image type. /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered to include only those containing the specified person. - /// Optional. If specified, results will be filtered to include only those containing the specified person id. - /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. + /// Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited. + /// Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited. + /// Optional. If specified, person results will be filtered on items related to said persons. /// User id. - /// Optional filter by items whose name is sorted equally or greater than a given input string. - /// Optional filter by items whose name is sorted equally than a given input string. - /// Optional filter by items whose name is equally or lesser than a given input string. /// Optional, include image information in output. - /// Optional. Include total record count. /// Persons returned. /// An containing the queryresult of persons. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetPersons( - [FromQuery] double? minCommunityRating, - [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? excludePersonTypes, [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery] string? appearsInItemId, [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) + [FromQuery] bool? enableImages = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = null; - BaseItem parentItem; if (userId.HasValue && !userId.Equals(Guid.Empty)) { user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); } - var query = new InternalItemsQuery(user) + var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); + var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery { - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(), - MinCommunityRating = minCommunityRating, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount - }; - - if (!string.IsNullOrWhiteSpace(parentId)) - { - if (parentItem is Folder) - { - query.AncestorIds = new[] { new Guid(parentId) }; - } - else - { - query.ItemIds = new[] { new Guid(parentId) }; - } - } - - // Studios - if (!string.IsNullOrEmpty(studios)) - { - query.StudioIds = studios.Split('|') - .Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null) - .Select(i => i!.Id) - .ToArray(); - } - - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } - - var result = new QueryResult<(BaseItem, ItemCounts)>(); - - var dtos = result.Items.Select(i => - { - var (baseItem, counts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; + ExcludePersonTypes = RequestHelpers.Split(excludePersonTypes, ',', true), + NameContains = searchTerm, + User = user, + IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, + AppearsInItemId = string.IsNullOrEmpty(appearsInItemId) ? Guid.Empty : Guid.Parse(appearsInItemId), + Limit = limit ?? 0 }); return new QueryResult { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount + Items = peopleItems.Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)).ToArray(), + TotalRecordCount = peopleItems.Count }; } diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 1e95bd2b38..4b3d8d3d39 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -5,11 +5,13 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.PlaylistDtos; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Playlists; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; @@ -133,7 +135,7 @@ namespace Jellyfin.Api.Controllers /// User id. /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Include image information in output. /// Optional. Include user data. /// Optional. The max number of images to return, per image type. @@ -147,11 +149,11 @@ namespace Jellyfin.Api.Controllers [FromQuery, Required] Guid userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var playlist = (Playlist)_libraryManager.GetItemById(playlistId); if (playlist == null) @@ -175,8 +177,7 @@ namespace Jellyfin.Api.Controllers items = items.Take(limit.Value).ToArray(); } - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs index ab7920895b..68e4f0586f 100644 --- a/Jellyfin.Api/Controllers/ScheduledTasksController.cs +++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs @@ -36,7 +36,7 @@ namespace Jellyfin.Api.Controllers /// The list of scheduled tasks. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable GetTasks( + public IEnumerable GetTasks( [FromQuery] bool? isHidden, [FromQuery] bool? isEnabled) { @@ -57,7 +57,7 @@ namespace Jellyfin.Api.Controllers } } - yield return task; + yield return ScheduledTaskHelpers.GetTaskInfo(task); } } diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 5656709629..e506ac7bf1 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -379,7 +379,7 @@ namespace Jellyfin.Api.Controllers public ActionResult PostCapabilities( [FromQuery] string? id, [FromQuery] string? playableMediaTypes, - [FromQuery] GeneralCommandType[] supportedCommands, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, [FromQuery] bool supportsMediaControl = false, [FromQuery] bool supportsSync = false, [FromQuery] bool supportsPersistentIdentifier = true) diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index 3a8ac1b243..27dcd51bc2 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -1,14 +1,15 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -45,30 +46,17 @@ namespace Jellyfin.Api.Controllers /// /// Gets all studios from a given item, folder, or the entire library. /// - /// Optional filter by minimum community rating. /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. /// Optional. Search term. /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional. Specify additional filters to apply. /// Optional filter by items that are marked as favorite, or not. - /// Optional filter by MediaType. Allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. /// Optional, include user data. /// Optional, the max number of images to return, per image type. /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered to include only those containing the specified person. - /// Optional. If specified, results will be filtered to include only those containing the specified person ids. - /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. /// User id. /// Optional filter by items whose name is sorted equally or greater than a given input string. /// Optional filter by items whose name is sorted equally than a given input string. @@ -80,30 +68,17 @@ namespace Jellyfin.Api.Controllers [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetStudios( - [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? excludeItemTypes, [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, @@ -111,49 +86,27 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - User? user = null; - BaseItem parentItem; + User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null; - if (userId.HasValue && !userId.Equals(Guid.Empty)) - { - user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } + var parentItem = _libraryManager.GetParentItem(parentId, userId); var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); var query = new InternalItemsQuery(user) { ExcludeItemTypes = excludeItemTypesArr, IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, ',', true), - OfficialRatings = RequestHelpers.Split(officialRatings, ',', true), - Genres = RequestHelpers.Split(genres, ',', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), - MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, EnableTotalRecordCount = enableTotalRecordCount @@ -171,84 +124,9 @@ namespace Jellyfin.Api.Controllers } } - // Studios - if (!string.IsNullOrEmpty(studios)) - { - query.StudioIds = studios.Split('|').Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null).Select(i => i!.Id) - .ToArray(); - } - - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } - - var result = new QueryResult<(BaseItem, ItemCounts)>(); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; + var result = _libraryManager.GetStudios(query); + var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } /// diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index cc682ed542..a01ae31a0e 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -11,6 +11,9 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.SubtitleDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -21,6 +24,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Subtitles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -34,6 +38,7 @@ namespace Jellyfin.Api.Controllers [Route("")] public class SubtitleController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ILibraryManager _libraryManager; private readonly ISubtitleManager _subtitleManager; private readonly ISubtitleEncoder _subtitleEncoder; @@ -46,6 +51,7 @@ namespace Jellyfin.Api.Controllers /// /// Initializes a new instance of the class. /// + /// Instance of interface. /// Instance of interface. /// Instance of interface. /// Instance of interface. @@ -55,6 +61,7 @@ namespace Jellyfin.Api.Controllers /// Instance of interface. /// Instance of interface. public SubtitleController( + IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, ISubtitleManager subtitleManager, ISubtitleEncoder subtitleEncoder, @@ -64,6 +71,7 @@ namespace Jellyfin.Api.Controllers IAuthorizationContext authContext, ILogger logger) { + _serverConfigurationManager = serverConfigurationManager; _libraryManager = libraryManager; _subtitleManager = subtitleManager; _subtitleEncoder = subtitleEncoder; @@ -319,6 +327,33 @@ namespace Jellyfin.Api.Controllers return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } + /// + /// Upload an external subtitle file. + /// + /// The item the subtitle belongs to. + /// The request body. + /// Subtitle uploaded. + /// A . + [HttpPost("Videos/{itemId}/Subtitles")] + public async Task UploadSubtitle( + [FromRoute, Required] Guid itemId, + [FromBody, Required] UploadSubtitleDto body) + { + var video = (Video)_libraryManager.GetItemById(itemId); + var data = Convert.FromBase64String(body.Data); + await using var memoryStream = new MemoryStream(data); + await _subtitleManager.UploadSubtitle( + video, + new SubtitleResponse + { + Format = body.Format, + Language = body.Language, + IsForced = body.IsForced, + Stream = memoryStream + }).ConfigureAwait(false); + return NoContent(); + } + /// /// Encodes a subtitle in the specified format. /// @@ -351,5 +386,95 @@ namespace Jellyfin.Api.Controllers copyTimestamps, CancellationToken.None); } + + /// + /// Gets a list of available fallback font files. + /// + /// Information retrieved. + /// An array of with the available font files. + [HttpGet("FallbackFont/Fonts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable GetFallbackFontList() + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var fallbackFontPath = encodingOptions.FallbackFontPath; + + if (!string.IsNullOrEmpty(fallbackFontPath)) + { + var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false); + var fontFiles = files + .Select(i => new FontFile + { + Name = i.Name, + Size = i.Length, + DateCreated = _fileSystem.GetCreationTimeUtc(i), + DateModified = _fileSystem.GetLastWriteTimeUtc(i) + }) + .OrderBy(i => i.Size) + .ThenBy(i => i.Name) + .ThenByDescending(i => i.DateModified) + .ThenByDescending(i => i.DateCreated); + // max total size 20M + const int MaxSize = 20971520; + var sizeCounter = 0L; + foreach (var fontFile in fontFiles) + { + sizeCounter += fontFile.Size; + if (sizeCounter >= MaxSize) + { + _logger.LogWarning("Some fonts will not be sent due to size limitations"); + yield break; + } + + yield return fontFile; + } + } + else + { + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + } + + /// + /// Gets a fallback font file. + /// + /// The name of the fallback font file to get. + /// Fallback font file retrieved. + /// The fallback font file. + [HttpGet("FallbackFont/Fonts/{name}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetFallbackFont([FromRoute, Required] string name) + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var fallbackFontPath = encodingOptions.FallbackFontPath; + + if (!string.IsNullOrEmpty(fallbackFontPath)) + { + var fontFile = _fileSystem.GetFiles(fallbackFontPath) + .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + var fileSize = fontFile?.Length; + + if (fontFile != null && fileSize != null && fileSize > 0) + { + _logger.LogDebug("Fallback font size is {fileSize} Bytes", fileSize); + return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); + } + else + { + _logger.LogWarning("The selected font is null or empty"); + } + } + else + { + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + + // returning HTTP 204 will break the SubtitlesOctopus + return Ok(); + } } } diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index d7c81a3ab6..ad64adfbad 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; +using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; @@ -9,6 +10,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -18,6 +20,7 @@ namespace Jellyfin.Api.Controllers /// The suggestions controller. /// [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] public class SuggestionsController : BaseJellyfinApiController { private readonly IDtoService _dtoService; diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index dec4498575..d78adcbcdc 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -1,5 +1,6 @@ using System; using Jellyfin.Api.Constants; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -125,7 +126,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isHd, [FromQuery] bool? is4K, [FromQuery] string? locationTypes, - [FromQuery] LocationType[] excludeLocationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, [FromQuery] double? minCommunityRating, @@ -145,12 +146,12 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? searchTerm, [FromQuery] string? sortOrder, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? excludeItemTypes, - [FromQuery] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, [FromQuery] string? mediaTypes, - [FromQuery] string? imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, [FromQuery] string? sortBy, [FromQuery] bool? isPlayed, [FromQuery] string? genres, @@ -159,7 +160,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, [FromQuery] string? personIds, [FromQuery] string? personTypes, diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index d158f6c342..6fd154836b 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; @@ -13,6 +14,7 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.TV; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -57,7 +59,7 @@ namespace Jellyfin.Api.Controllers /// The user id of the user to get the next up episodes for. /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Filter by series id. /// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root. /// Optional. Include image information in output. @@ -72,17 +74,16 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? seriesId, [FromQuery] string? parentId, [FromQuery] bool? enableImges, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) { - var options = new DtoOptions() - .AddItemFields(fields!) + var options = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); @@ -117,7 +118,7 @@ namespace Jellyfin.Api.Controllers /// The user id of the user to get the upcoming episodes for. /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// Optional. Specify additional fields of information to return in the output. /// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root. /// Optional. Include image information in output. /// Optional. The max number of images to return, per image type. @@ -130,11 +131,11 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? parentId, [FromQuery] bool? enableImges, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -145,8 +146,7 @@ namespace Jellyfin.Api.Controllers var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); - var options = new DtoOptions() - .AddItemFields(fields!) + var options = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); @@ -196,7 +196,7 @@ namespace Jellyfin.Api.Controllers public ActionResult> GetEpisodes( [FromRoute, Required] string seriesId, [FromQuery] Guid? userId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] int? season, [FromQuery] string? seasonId, [FromQuery] bool? isMissing, @@ -206,7 +206,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] string? sortBy) { @@ -216,8 +216,7 @@ namespace Jellyfin.Api.Controllers List episodes; - var dtoOptions = new DtoOptions() - .AddItemFields(fields!) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); @@ -319,13 +318,13 @@ namespace Jellyfin.Api.Controllers public ActionResult> GetSeasons( [FromRoute, Required] string seriesId, [FromQuery] Guid? userId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? isSpecialSeason, [FromQuery] bool? isMissing, [FromQuery] string? adjacentTo, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -344,8 +343,7 @@ namespace Jellyfin.Api.Controllers AdjacentTo = adjacentTo }); - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index df20a92b3d..e10f1fe912 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -76,6 +76,7 @@ namespace Jellyfin.Api.Controllers /// Optional. The maximum number of audio channels. /// Optional. The number of how many audio channels to transcode to. /// Optional. The maximum streaming bitrate. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. /// Optional. The container to transcode to. /// Optional. The transcoding protocol. @@ -88,23 +89,22 @@ namespace Jellyfin.Api.Controllers /// Redirected to remote audio stream. /// A containing the audio file. [HttpGet("Audio/{itemId}/universal")] - [HttpGet("Audio/{itemId}/universal.{container}", Name = "GetUniversalAudioStream_2")] [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] - [HttpHead("Audio/{itemId}/universal.{container}", Name = "HeadUniversalAudioStream_2")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesAudioFile] public async Task GetUniversalAudioStream( [FromRoute, Required] Guid itemId, - [FromRoute] string? container, + [FromQuery] string? container, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, [FromQuery] Guid? userId, [FromQuery] string? audioCodec, [FromQuery] int? maxAudioChannels, [FromQuery] int? transcodingAudioChannels, - [FromQuery] long? maxStreamingBitrate, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, [FromQuery] long? startTimeTicks, [FromQuery] string? transcodingContainer, [FromQuery] string? transcodingProtocol, @@ -212,7 +212,7 @@ namespace Jellyfin.Api.Controllers AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + AudioBitRate = audioBitRate ?? maxStreamingBitrate, StartTimeTicks = startTimeTicks, SubtitleMethod = SubtitleDeliveryMethod.Hls, RequireAvc = true, @@ -244,7 +244,7 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + AudioBitRate = isStatic ? (int?)null : (audioBitRate ?? maxStreamingBitrate), MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = maxAudioChannels, CopyTimestamps = true, @@ -270,20 +270,24 @@ namespace Jellyfin.Api.Controllers { var deviceProfile = new DeviceProfile(); - var directPlayProfiles = new List(); - var containers = RequestHelpers.Split(container, ',', true); - - foreach (var cont in containers) + int len = containers.Length; + var directPlayProfiles = new DirectPlayProfile[len]; + for (int i = 0; i < len; i++) { - var parts = RequestHelpers.Split(cont, ',', true); + var parts = RequestHelpers.Split(containers[i], '|', true); - var audioCodecs = parts.Length == 1 ? null : string.Join(",", parts.Skip(1).ToArray()); + var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1)); - directPlayProfiles.Add(new DirectPlayProfile { Type = DlnaProfileType.Audio, Container = parts[0], AudioCodec = audioCodecs }); + directPlayProfiles[i] = new DirectPlayProfile + { + Type = DlnaProfileType.Audio, + Container = parts[0], + AudioCodec = audioCodecs + }; } - deviceProfile.DirectPlayProfiles = directPlayProfiles.ToArray(); + deviceProfile.DirectPlayProfiles = directPlayProfiles; deviceProfile.TranscodingProfiles = new[] { diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 50bb8bb2aa..0f7c25d0e4 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -381,17 +381,13 @@ namespace Jellyfin.Api.Controllers var user = _userManager.GetUserById(userId); - if (string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) - { - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - _userManager.UpdateConfiguration(user.Id, updateUser.Configuration); - } - else + if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) { await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); - _userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration); } + await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); + return NoContent(); } @@ -409,7 +405,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public ActionResult UpdateUserPolicy( + public async Task UpdateUserPolicy( [FromRoute, Required] Guid userId, [FromBody] UserPolicy newPolicy) { @@ -447,7 +443,7 @@ namespace Jellyfin.Api.Controllers _sessionManager.RevokeUserTokens(user.Id, currentToken); } - _userManager.UpdatePolicy(userId, newPolicy); + await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); return NoContent(); } @@ -464,7 +460,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public ActionResult UpdateUserConfiguration( + public async Task UpdateUserConfiguration( [FromRoute, Required] Guid userId, [FromBody] UserConfiguration userConfig) { @@ -473,7 +469,7 @@ namespace Jellyfin.Api.Controllers return Forbid("User configuration update not allowed"); } - _userManager.UpdateConfiguration(userId, userConfig); + await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); return NoContent(); } @@ -534,6 +530,33 @@ namespace Jellyfin.Api.Controllers return result; } + /// + /// Gets the user based on auth token. + /// + /// User returned. + /// Token is not owned by a user. + /// A for the authenticated user. + [HttpGet("Me")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult GetCurrentUser() + { + var userId = ClaimHelpers.GetUserId(Request.HttpContext.User); + if (userId == null) + { + return BadRequest(); + } + + var user = _userManager.GetUserById(userId.Value); + if (user == null) + { + return BadRequest(); + } + + return _userManager.GetUserDto(user); + } + private IEnumerable Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) { var users = _userManager.Users; diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 48262f0620..cfd8511297 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -251,7 +252,7 @@ namespace Jellyfin.Api.Controllers /// /// User id. /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted. /// Filter by items that are played, or not. /// Optional. include image information in output. @@ -267,12 +268,12 @@ namespace Jellyfin.Api.Controllers public ActionResult> GetLatestMedia( [FromRoute, Required] Guid userId, [FromQuery] Guid? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? includeItemTypes, [FromQuery] bool? isPlayed, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] int limit = 20, [FromQuery] bool groupItems = true) @@ -287,8 +288,7 @@ namespace Jellyfin.Api.Controllers } } - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs index 2afa878f41..d7bcf79c1f 100644 --- a/Jellyfin.Api/Controllers/VideoHlsController.cs +++ b/Jellyfin.Api/Controllers/VideoHlsController.cs @@ -371,7 +371,7 @@ namespace Jellyfin.Api.Controllers var baseUrlParam = string.Format( CultureInfo.InvariantCulture, - "\"hls{0}\"", + "\"hls/{0}/\"", Path.GetFileNameWithoutExtension(outputPath)); return string.Format( diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index 4ecf0407bf..1b38e399d6 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -5,11 +5,13 @@ using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -50,7 +52,7 @@ namespace Jellyfin.Api.Controllers /// Optional. The maximum number of records to return. /// Sort Order - Ascending,Descending. /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. Specify additional fields of information to return in the output. /// Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited. /// Optional. If specified, results will be included based on item type. This allows multiple, comma delimited. /// Optional. Filter by MediaType. Allows multiple, comma delimited. @@ -70,20 +72,19 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? sortOrder, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? excludeItemTypes, [FromQuery] string? includeItemTypes, [FromQuery] string? mediaTypes, [FromQuery] string? sortBy, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] bool recursive = true, [FromQuery] bool? enableImages = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index e61e9c29d9..6dee9db380 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -13,42 +13,6 @@ namespace Jellyfin.Api.Extensions /// public static class DtoExtensions { - /// - /// Add Dto Item fields. - /// - /// - /// Converted from IHasItemFields. - /// Legacy order: 1. - /// - /// DtoOptions object. - /// Comma delimited string of fields. - /// Modified DtoOptions object. - internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string? fields) - { - if (string.IsNullOrEmpty(fields)) - { - dtoOptions.Fields = Array.Empty(); - } - else - { - dtoOptions.Fields = fields.Split(',') - .Select(v => - { - if (Enum.TryParse(v, true, out ItemFields value)) - { - return (ItemFields?)value; - } - - return null; - }) - .Where(i => i.HasValue) - .Select(i => i!.Value) - .ToArray(); - } - - return dtoOptions; - } - /// /// Add additional fields depending on client. /// @@ -126,7 +90,7 @@ namespace Jellyfin.Api.Extensions bool? enableImages, bool? enableUserData, int? imageTypeLimit, - string? enableImageTypes) + ImageType[] enableImageTypes) { dtoOptions.EnableImages = enableImages ?? true; @@ -140,11 +104,9 @@ namespace Jellyfin.Api.Extensions dtoOptions.EnableUserData = enableUserData.Value; } - if (!string.IsNullOrWhiteSpace(enableImageTypes)) + if (enableImageTypes.Length != 0) { - dtoOptions.ImageTypes = enableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true)) - .ToArray(); + dtoOptions.ImageTypes = enableImageTypes; } return dtoOptions; diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs index df235ced25..29e6b4193e 100644 --- a/Jellyfin.Api/Helpers/ClaimHelpers.cs +++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs @@ -63,6 +63,19 @@ namespace Jellyfin.Api.Helpers public static string? GetToken(in ClaimsPrincipal user) => GetClaimValue(user, InternalClaimTypes.Token); + /// + /// Gets a flag specifying whether the request is using an api key. + /// + /// Current claims principal. + /// The flag specifying whether the request is using an api key. + public static bool GetIsApiKey(in ClaimsPrincipal user) + { + var claimValue = GetClaimValue(user, InternalClaimTypes.IsApiKey); + return !string.IsNullOrEmpty(claimValue) + && bool.TryParse(claimValue, out var parsedClaimValue) + && parsedClaimValue; + } + private static string? GetClaimValue(in ClaimsPrincipal user, string name) { return user?.Identities diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index af0519ffa8..ea012f8376 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -155,7 +155,7 @@ namespace Jellyfin.Api.Helpers return new FileContentResult(Array.Empty(), MimeTypes.GetMimeType("playlist.m3u8")); } - var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0; + var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0); var builder = new StringBuilder(); diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 6b516977e8..366301d3ee 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -123,9 +123,8 @@ namespace Jellyfin.Api.Helpers state.Dispose(); } - await new ProgressiveFileCopier(outputPath, job, transcodingJobHelper, CancellationToken.None) - .WriteToAsync(httpContext.Response.Body, CancellationToken.None).ConfigureAwait(false); - return new FileStreamResult(httpContext.Response.Body, contentType); + var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper); + return new FileStreamResult(stream, contentType); } finally { diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index e78f63b256..0d8315dee1 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -166,7 +166,7 @@ namespace Jellyfin.Api.Helpers MediaSourceInfo mediaSource, DeviceProfile profile, AuthorizationInfo auth, - long? maxBitrate, + int? maxBitrate, long startTimeTicks, string mediaSourceId, int? audioStreamIndex, @@ -551,7 +551,7 @@ namespace Jellyfin.Api.Helpers } } - private long? GetMaxBitrate(long? clientMaxBitrate, User user, string ipAddress) + private int? GetMaxBitrate(int? clientMaxBitrate, User user, string ipAddress) { var maxBitrate = clientMaxBitrate; var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs new file mode 100644 index 0000000000..824870c7ef --- /dev/null +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Model.IO; + +namespace Jellyfin.Api.Helpers +{ + /// + /// A progressive file stream for transferring transcoded files as they are written to. + /// + public class ProgressiveFileStream : Stream + { + private readonly FileStream _fileStream; + private readonly TranscodingJobDto? _job; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly bool _allowAsyncFileRead; + private int _bytesWritten; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the transcoded file. + /// The transcoding job information. + /// The transcoding job helper. + public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper) + { + _job = job; + _transcodingJobHelper = transcodingJobHelper; + _bytesWritten = 0; + + var fileOptions = FileOptions.SequentialScan; + _allowAsyncFileRead = false; + + // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fileOptions |= FileOptions.Asynchronous; + _allowAsyncFileRead = true; + } + + _fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); + } + + /// + public override bool CanRead => _fileStream.CanRead; + + /// + public override bool CanSeek => false; + + /// + public override bool CanWrite => false; + + /// + public override long Length => throw new NotSupportedException(); + + /// + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + /// + public override void Flush() + { + _fileStream.Flush(); + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + return _fileStream.Read(buffer, offset, count); + } + + /// + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + int totalBytesRead = 0; + int remainingBytesToRead = count; + + int newOffset = offset; + while (remainingBytesToRead > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + int bytesRead; + if (_allowAsyncFileRead) + { + bytesRead = await _fileStream.ReadAsync(buffer, newOffset, remainingBytesToRead, cancellationToken).ConfigureAwait(false); + } + else + { + bytesRead = _fileStream.Read(buffer, newOffset, remainingBytesToRead); + } + + remainingBytesToRead -= bytesRead; + newOffset += bytesRead; + + if (bytesRead > 0) + { + _bytesWritten += bytesRead; + totalBytesRead += bytesRead; + + if (_job != null) + { + _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); + } + } + else + { + // If the job is null it's a live stream and will require user action to close + if (_job?.HasExited ?? false) + { + break; + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + + return totalBytesRead; + } + + /// + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + /// + public override void SetLength(long value) + => throw new NotSupportedException(); + + /// + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + /// + protected override void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + try + { + if (disposing) + { + _fileStream.Dispose(); + + if (_job != null) + { + _transcodingJobHelper.OnTranscodeEndRequest(_job); + } + } + } + finally + { + _disposed = true; + base.Dispose(disposing); + } + } + } +} diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index fb5c283f59..13d6da3174 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -1,11 +1,14 @@ using System; -using System.Collections.Generic; using System.Linq; -using System.Net; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Http; @@ -161,5 +164,40 @@ namespace Jellyfin.Api.Helpers .Select(i => i!.Value) .ToArray(); } + + internal static QueryResult CreateQueryResult( + QueryResult<(BaseItem, ItemCounts)> result, + DtoOptions dtoOptions, + IDtoService dtoService, + bool includeItemTypes, + User? user) + { + var dtos = result.Items.Select(i => + { + var (baseItem, counts) = i; + var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); + + if (includeItemTypes) + { + dto.ChildCount = counts.ItemCount; + dto.ProgramCount = counts.ProgramCount; + dto.SeriesCount = counts.SeriesCount; + dto.EpisodeCount = counts.EpisodeCount; + dto.MovieCount = counts.MovieCount; + dto.TrailerCount = counts.TrailerCount; + dto.AlbumCount = counts.AlbumCount; + dto.SongCount = counts.SongCount; + dto.ArtistCount = counts.ArtistCount; + } + + return dto; + }); + + return new QueryResult + { + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; + } } } diff --git a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs deleted file mode 100644 index b922e76cfd..0000000000 --- a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; - -namespace Jellyfin.Api.Helpers -{ - /// - /// The similar items helper class. - /// - public static class SimilarItemsHelper - { - internal static QueryResult GetSimilarItemsResult( - DtoOptions dtoOptions, - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - Guid? userId, - string id, - string? excludeArtistIds, - int? limit, - Type[] includeTypes, - Func, List, BaseItem, int> getSimilarityScore) - { - var user = userId.HasValue && !userId.Equals(Guid.Empty) - ? userManager.GetUserById(userId.Value) - : null; - - var item = string.IsNullOrEmpty(id) ? - (!userId.Equals(Guid.Empty) ? libraryManager.GetUserRootFolder() : - libraryManager.RootFolder) : libraryManager.GetItemById(id); - - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = includeTypes.Select(i => i.Name).ToArray(), - Recursive = true, - DtoOptions = dtoOptions, - ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds) - }; - - var inputItems = libraryManager.GetItemList(query); - - var items = GetSimilaritems(item, libraryManager, inputItems, getSimilarityScore) - .ToList(); - - var returnItems = items; - - if (limit.HasValue) - { - returnItems = returnItems.Take(limit.Value).ToList(); - } - - var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); - - return new QueryResult - { - Items = dtos, - TotalRecordCount = items.Count - }; - } - - /// - /// Gets the similaritems. - /// - /// The item. - /// The library manager. - /// The input items. - /// The get similarity score. - /// IEnumerable{BaseItem}. - private static IEnumerable GetSimilaritems( - BaseItem item, - ILibraryManager libraryManager, - IEnumerable inputItems, - Func, List, BaseItem, int> getSimilarityScore) - { - var itemId = item.Id; - inputItems = inputItems.Where(i => i.Id != itemId); - var itemPeople = libraryManager.GetPeople(item); - var allPeople = libraryManager.GetPeople(new InternalPeopleQuery - { - AppearsInItemId = item.Id - }); - - return inputItems.Select(i => new Tuple(i, getSimilarityScore(item, itemPeople, allPeople, i))) - .Where(i => i.Item2 > 2) - .OrderByDescending(i => i.Item2) - .Select(i => i.Item1); - } - - private static IEnumerable GetTags(BaseItem item) - { - return item.Tags; - } - - /// - /// Gets the similiarity score. - /// - /// The item1. - /// The item1 people. - /// All people. - /// The item2. - /// System.Int32. - internal static int GetSimiliarityScore(BaseItem item1, List item1People, List allPeople, BaseItem item2) - { - var points = 0; - - if (!string.IsNullOrEmpty(item1.OfficialRating) && string.Equals(item1.OfficialRating, item2.OfficialRating, StringComparison.OrdinalIgnoreCase)) - { - points += 10; - } - - // Find common genres - points += item1.Genres.Where(i => item2.Genres.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10); - - // Find common tags - points += GetTags(item1).Where(i => GetTags(item2).Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10); - - // Find common studios - points += item1.Studios.Where(i => item2.Studios.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 3); - - var item2PeopleNames = allPeople.Where(i => i.ItemId == item2.Id) - .Select(i => i.Name) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .DistinctNames() - .ToDictionary(i => i, StringComparer.OrdinalIgnoreCase); - - points += item1People.Where(i => item2PeopleNames.ContainsKey(i.Name)).Sum(i => - { - if (string.Equals(i.Type, PersonType.Director, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Director, StringComparison.OrdinalIgnoreCase)) - { - return 5; - } - - if (string.Equals(i.Type, PersonType.Actor, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Actor, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (string.Equals(i.Type, PersonType.Composer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Composer, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (string.Equals(i.Type, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (string.Equals(i.Type, PersonType.Writer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Writer, StringComparison.OrdinalIgnoreCase)) - { - return 2; - } - - return 1; - }); - - if (item1.ProductionYear.HasValue && item2.ProductionYear.HasValue) - { - var diff = Math.Abs(item1.ProductionYear.Value - item2.ProductionYear.Value); - - // Add if they came out within the same decade - if (diff < 10) - { - points += 2; - } - - // And more if within five years - if (diff < 5) - { - points += 2; - } - } - - return points; - } - } -} diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs index 208566dc8e..4f012cab20 100644 --- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Api.ModelBinders public Task BindModelAsync(ModelBindingContext bindingContext) { var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - var elementType = bindingContext.ModelType.GetElementType(); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; var converter = TypeDescriptor.GetConverter(elementType); if (valueProviderResult.Length > 1) diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs deleted file mode 100644 index b9785a73b8..0000000000 --- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace Jellyfin.Api.ModelBinders -{ - /// - /// Comma delimited array model binder provider. - /// - public class CommaDelimitedArrayModelBinderProvider : IModelBinderProvider - { - private readonly IModelBinder _binder; - - /// - /// Initializes a new instance of the class. - /// - public CommaDelimitedArrayModelBinderProvider() - { - _binder = new CommaDelimitedArrayModelBinder(); - } - - /// - public IModelBinder? GetBinder(ModelBinderProviderContext context) - { - return context.Metadata.ModelType.IsArray ? _binder : null; - } - } -} diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs index d7eaab30de..aa98651922 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -1,4 +1,9 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using MediaBrowser.Common.Json.Converters; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; namespace Jellyfin.Api.Models.LiveTvDtos { @@ -137,7 +142,9 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// Gets or sets the image types to include in the output. /// Optional. /// - public string? EnableImageTypes { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "EnableImageTypes", Justification = "Imported from ServiceStack")] + public ImageType[] EnableImageTypes { get; set; } = Array.Empty(); /// /// Gets or sets include user data. @@ -161,6 +168,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// Gets or sets specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. /// Optional. /// - public string? Fields { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "Fields", Justification = "Imported from ServiceStack")] + public ItemFields[] Fields { get; set; } = Array.Empty(); } } diff --git a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs new file mode 100644 index 0000000000..30473255e8 --- /dev/null +++ b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace Jellyfin.Api.Models.SubtitleDtos +{ + /// + /// Upload subtitles dto. + /// + public class UploadSubtitleDto + { + /// + /// Gets or sets the subtitle language. + /// + [Required] + public string Language { get; set; } = string.Empty; + + /// + /// Gets or sets the subtitle format. + /// + [Required] + public string Format { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the subtitle is forced. + /// + [Required] + public bool IsForced { get; set; } + + /// + /// Gets or sets the subtitle data. + /// + [Required] + public string Data { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 5926abfe0d..7bde4f35be 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -72,6 +72,18 @@ namespace Jellyfin.Server.Implementations.Activity }; } + /// + public async Task CleanAsync(DateTime startDate) + { + await using var dbContext = _provider.CreateContext(); + var entries = dbContext.ActivityLogs + .AsQueryable() + .Where(entry => entry.DateCreated <= startDate); + + dbContext.RemoveRange(entries); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + private static ActivityLogEntry ConvertToOldModel(ActivityLog entry) { return new ActivityLogEntry diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 437833aa38..40b89ed284 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -2,6 +2,7 @@ #pragma warning disable CA1307 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -48,6 +49,8 @@ namespace Jellyfin.Server.Implementations.Users private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider; private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; + private readonly IDictionary _users; + /// /// Initializes a new instance of the class. /// @@ -81,38 +84,28 @@ namespace Jellyfin.Server.Implementations.Users _invalidAuthProvider = _authenticationProviders.OfType().First(); _defaultAuthenticationProvider = _authenticationProviders.OfType().First(); _defaultPasswordResetProvider = _passwordResetProviders.OfType().First(); + + _users = new ConcurrentDictionary(); + using var dbContext = _dbProvider.CreateContext(); + foreach (var user in dbContext.Users + .Include(user => user.Permissions) + .Include(user => user.Preferences) + .Include(user => user.AccessSchedules) + .Include(user => user.ProfileImage) + .AsEnumerable()) + { + _users.Add(user.Id, user); + } } /// public event EventHandler>? OnUserUpdated; /// - public IEnumerable Users - { - get - { - using var dbContext = _dbProvider.CreateContext(); - return dbContext.Users - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .ToList(); - } - } + public IEnumerable Users => _users.Values; /// - public IEnumerable UsersIds - { - get - { - using var dbContext = _dbProvider.CreateContext(); - return dbContext.Users - .AsQueryable() - .Select(user => user.Id) - .ToList(); - } - } + public IEnumerable UsersIds => _users.Keys; /// public User? GetUserById(Guid id) @@ -122,13 +115,8 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Guid can't be empty", nameof(id)); } - using var dbContext = _dbProvider.CreateContext(); - return dbContext.Users - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .FirstOrDefault(user => user.Id == id); + _users.TryGetValue(id, out var user); + return user; } /// @@ -139,14 +127,7 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Invalid username", nameof(name)); } - using var dbContext = _dbProvider.CreateContext(); - return dbContext.Users - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .AsEnumerable() - .FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); + return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); } /// @@ -205,13 +186,17 @@ namespace Jellyfin.Server.Implementations.Users ? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false) : 0; - return new User( + var user = new User( name, _defaultAuthenticationProvider.GetType().FullName, _defaultPasswordResetProvider.GetType().FullName) { InternalId = max + 1 }; + + _users.Add(user.Id, user); + + return user; } /// @@ -237,28 +222,12 @@ namespace Jellyfin.Server.Implementations.Users /// public void DeleteUser(Guid userId) { - using var dbContext = _dbProvider.CreateContext(); - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .FirstOrDefault(u => u.Id == userId); - if (user == null) + if (!_users.TryGetValue(userId, out var user)) { throw new ResourceNotFoundException(nameof(userId)); } - if (dbContext.Users.Find(user.Id) == null) - { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "The user cannot be deleted because there is no user with the Name {0} and Id {1}.", - user.Username, - user.Id)); - } - - if (dbContext.Users.Count() == 1) + if (_users.Count == 1) { throw new InvalidOperationException(string.Format( CultureInfo.InvariantCulture, @@ -277,6 +246,8 @@ namespace Jellyfin.Server.Implementations.Users nameof(userId)); } + using var dbContext = _dbProvider.CreateContext(); + // Clear all entities related to the user from the database. if (user.ProfileImage != null) { @@ -288,6 +259,7 @@ namespace Jellyfin.Server.Implementations.Users dbContext.RemoveRange(user.AccessSchedules); dbContext.Users.Remove(user); dbContext.SaveChanges(); + _users.Remove(userId); _eventManager.Publish(new UserDeletedEventArgs(user)); } @@ -460,11 +432,9 @@ namespace Jellyfin.Server.Implementations.Users // the authentication provider might have created it user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); - if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy) + if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user != null) { - UpdatePolicy(user.Id, hasNewUserPolicy.GetNewUserPolicy()); - - await UpdateUserAsync(user).ConfigureAwait(false); + await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); } } } @@ -589,9 +559,7 @@ namespace Jellyfin.Server.Implementations.Users public async Task InitializeAsync() { // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. - await using var dbContext = _dbProvider.CreateContext(); - - if (await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false)) + if (_users.Any()) { return; } @@ -604,6 +572,7 @@ namespace Jellyfin.Server.Implementations.Users _logger.LogWarning("No users, creating one with username {UserName}", defaultName); + await using var dbContext = _dbProvider.CreateContext(); var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); newUser.SetPermission(PermissionKind.IsAdministrator, true); newUser.SetPermission(PermissionKind.EnableContentDeletion, true); @@ -644,9 +613,9 @@ namespace Jellyfin.Server.Implementations.Users } /// - public void UpdateConfiguration(Guid userId, UserConfiguration config) + public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) { - using var dbContext = _dbProvider.CreateContext(); + await using var dbContext = _dbProvider.CreateContext(); var user = dbContext.Users .Include(u => u.Permissions) .Include(u => u.Preferences) @@ -673,13 +642,13 @@ namespace Jellyfin.Server.Implementations.Users user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); dbContext.Update(user); - dbContext.SaveChanges(); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } /// - public void UpdatePolicy(Guid userId, UserPolicy policy) + public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) { - using var dbContext = _dbProvider.CreateContext(); + await using var dbContext = _dbProvider.CreateContext(); var user = dbContext.Users .Include(u => u.Permissions) .Include(u => u.Preferences) @@ -744,15 +713,16 @@ namespace Jellyfin.Server.Implementations.Users user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); dbContext.Update(user); - dbContext.SaveChanges(); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } /// - public void ClearProfileImage(User user) + public async Task ClearProfileImageAsync(User user) { - using var dbContext = _dbProvider.CreateContext(); + await using var dbContext = _dbProvider.CreateContext(); dbContext.Remove(user.ProfileImage); - dbContext.SaveChanges(); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + user.ProfileImage = null; } private static bool IsValidUsername(string name) diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index d7b9da5c2f..cc98955df7 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Net; using System.Reflection; +using Emby.Server.Implementations; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Auth.DownloadPolicy; @@ -16,7 +17,6 @@ using Jellyfin.Api.Auth.LocalAccessPolicy; using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; -using Jellyfin.Api.ModelBinders; using Jellyfin.Server.Configuration; using Jellyfin.Server.Filters; using Jellyfin.Server.Formatters; @@ -28,6 +28,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes; @@ -167,8 +169,6 @@ namespace Jellyfin.Server.Extensions opts.OutputFormatters.Add(new CssOutputFormatter()); opts.OutputFormatters.Add(new XmlOutputFormatter()); - - opts.ModelBinderProviders.Insert(0, new CommaDelimitedArrayModelBinderProvider()); }) // Clear app parts to avoid other assemblies being picked up @@ -212,7 +212,19 @@ namespace Jellyfin.Server.Extensions { return serviceCollection.AddSwaggerGen(c => { - c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + c.SwaggerDoc("api-docs", new OpenApiInfo + { + Title = "Jellyfin API", + Version = "v1", + Extensions = new Dictionary + { + { + "x-jellyfin-version", + new OpenApiString(typeof(ApplicationHost).Assembly.GetName().Version?.ToString()) + } + } + }); + c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme { Type = SecuritySchemeType.ApiKey, diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 7de34f4160..3558f144cf 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -42,15 +42,15 @@ - - + + - + diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 844dae61f6..aca1654084 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -23,7 +23,8 @@ namespace Jellyfin.Server.Migrations typeof(Routines.AddDefaultPluginRepository), typeof(Routines.MigrateUserDb), typeof(Routines.ReaddDefaultPluginRepository), - typeof(Routines.MigrateDisplayPreferencesDb) + typeof(Routines.MigrateDisplayPreferencesDb), + typeof(Routines.RemoveDownloadImagesInAdvance) }; /// diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs new file mode 100644 index 0000000000..42b87ec5f5 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs @@ -0,0 +1,46 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// + /// Removes the old 'RemoveDownloadImagesInAdvance' from library options. + /// + internal class RemoveDownloadImagesInAdvance : IMigrationRoutine + { + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + + public RemoveDownloadImagesInAdvance(ILogger logger, ILibraryManager libraryManager) + { + _logger = logger; + _libraryManager = libraryManager; + } + + /// + public Guid Id => Guid.Parse("{A81F75E0-8F43-416F-A5E8-516CCAB4D8CC}"); + + /// + public string Name => "RemoveDownloadImagesInAdvance"; + + /// + public bool PerformOnNewInstall => false; + + /// + public void Perform() + { + var virtualFolders = _libraryManager.GetVirtualFolders(false); + _logger.LogInformation("Removing 'RemoveDownloadImagesInAdvance' settings in all the libraries"); + foreach (var virtualFolder in virtualFolders) + { + var libraryOptions = virtualFolder.LibraryOptions; + var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(virtualFolder.ItemId); + // 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/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 5573c04395..97a51c202b 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -290,23 +290,19 @@ namespace Jellyfin.Server { _logger.LogInformation("Kestrel listening on {IpAddress}", address); options.Listen(address, appHost.HttpPort); + if (appHost.ListenWithHttps) { - options.Listen(address, appHost.HttpsPort, listenOptions => - { - listenOptions.UseHttps(appHost.Certificate); - listenOptions.Protocols = HttpProtocols.Http1AndHttp2; - }); + options.Listen( + address, + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps(appHost.Certificate)); } else if (builderContext.HostingEnvironment.IsDevelopment()) { try { - options.Listen(address, appHost.HttpsPort, listenOptions => - { - listenOptions.UseHttps(); - listenOptions.Protocols = HttpProtocols.Http1AndHttp2; - }); + options.Listen(address, appHost.HttpsPort, listenOptions => listenOptions.UseHttps()); } catch (InvalidOperationException ex) { @@ -322,21 +318,15 @@ namespace Jellyfin.Server if (appHost.ListenWithHttps) { - options.ListenAnyIP(appHost.HttpsPort, listenOptions => - { - listenOptions.UseHttps(appHost.Certificate); - listenOptions.Protocols = HttpProtocols.Http1AndHttp2; - }); + options.ListenAnyIP( + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps(appHost.Certificate)); } else if (builderContext.HostingEnvironment.IsDevelopment()) { try { - options.ListenAnyIP(appHost.HttpsPort, listenOptions => - { - listenOptions.UseHttps(); - listenOptions.Protocols = HttpProtocols.Http1AndHttp2; - }); + options.ListenAnyIP(appHost.HttpsPort, listenOptions => listenOptions.UseHttps()); } catch (InvalidOperationException ex) { diff --git a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs new file mode 100644 index 0000000000..bf7048c370 --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs @@ -0,0 +1,53 @@ +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// + /// Convert comma delimited string to array of type. + /// + /// Type to convert to. + public class JsonCommaDelimitedArrayConverter : JsonConverter + { + private readonly TypeConverter _typeConverter; + + /// + /// Initializes a new instance of the class. + /// + public JsonCommaDelimitedArrayConverter() + { + _typeConverter = TypeDescriptor.GetConverter(typeof(T)); + } + + /// + public override T[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var stringEntries = reader.GetString()?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + if (stringEntries == null || stringEntries.Length == 0) + { + return Array.Empty(); + } + + var entries = new T[stringEntries.Length]; + for (var i = 0; i < stringEntries.Length; i++) + { + entries[i] = (T)_typeConverter.ConvertFrom(stringEntries[i].Trim()); + } + + return entries; + } + + return JsonSerializer.Deserialize(ref reader, options); + } + + /// + public override void Write(Utf8JsonWriter writer, T[] value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, options); + } + } +} \ No newline at end of file diff --git a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs new file mode 100644 index 0000000000..24ed3ea19e --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// + /// Json comma delimited array converter factory. + /// + /// + /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow. + /// + public class JsonCommaDelimitedArrayConverterFactory : JsonConverterFactory + { + /// + public override bool CanConvert(Type typeToConvert) + { + return true; + } + + /// + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0]; + return (JsonConverter)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType)); + } + } +} diff --git a/MediaBrowser.Common/Plugins/LocalPlugin.cs b/MediaBrowser.Common/Plugins/LocalPlugin.cs new file mode 100644 index 0000000000..7927c663d4 --- /dev/null +++ b/MediaBrowser.Common/Plugins/LocalPlugin.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace MediaBrowser.Common.Plugins +{ + /// + /// Local plugin struct. + /// + public class LocalPlugin : IEquatable + { + /// + /// Initializes a new instance of the class. + /// + /// The plugin id. + /// The plugin name. + /// The plugin version. + /// The plugin path. + public LocalPlugin(Guid id, string name, Version version, string path) + { + Id = id; + Name = name; + Version = version; + Path = path; + DllFiles = new List(); + } + + /// + /// Gets the plugin id. + /// + public Guid Id { get; } + + /// + /// Gets the plugin name. + /// + public string Name { get; } + + /// + /// Gets the plugin version. + /// + public Version Version { get; } + + /// + /// Gets the plugin path. + /// + public string Path { get; } + + /// + /// Gets the list of dll files for this plugin. + /// + public List DllFiles { get; } + + /// + /// == operator. + /// + /// Left item. + /// Right item. + /// Comparison result. + public static bool operator ==(LocalPlugin left, LocalPlugin right) + { + return left.Equals(right); + } + + /// + /// != operator. + /// + /// Left item. + /// Right item. + /// Comparison result. + public static bool operator !=(LocalPlugin left, LocalPlugin right) + { + return !left.Equals(right); + } + + /// + /// Compare two . + /// + /// The first item. + /// The second item. + /// Comparison result. + public static int Compare(LocalPlugin a, LocalPlugin b) + { + var compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture); + + // Id is not equal but name is. + if (a.Id != b.Id && compare == 0) + { + compare = a.Id.CompareTo(b.Id); + } + + return compare == 0 ? a.Version.CompareTo(b.Version) : compare; + } + + /// + public override bool Equals(object obj) + { + return obj is LocalPlugin other && this.Equals(other); + } + + /// + public override int GetHashCode() + { + return Name.GetHashCode(StringComparison.OrdinalIgnoreCase); + } + + /// + public bool Equals(LocalPlugin other) + { + return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) + && Id.Equals(other.Id); + } + } +} diff --git a/MediaBrowser.Common/Updates/IInstallationManager.cs b/MediaBrowser.Common/Updates/IInstallationManager.cs index 169aca2ca0..6aa16fea74 100644 --- a/MediaBrowser.Common/Updates/IInstallationManager.cs +++ b/MediaBrowser.Common/Updates/IInstallationManager.cs @@ -11,29 +11,6 @@ namespace MediaBrowser.Common.Updates { public interface IInstallationManager : IDisposable { - event EventHandler PackageInstalling; - - event EventHandler PackageInstallationCompleted; - - event EventHandler PackageInstallationFailed; - - event EventHandler PackageInstallationCancelled; - - /// - /// Occurs when a plugin is uninstalled. - /// - event EventHandler PluginUninstalled; - - /// - /// Occurs when a plugin is updated. - /// - event EventHandler PluginUpdated; - - /// - /// Occurs when a plugin is installed. - /// - event EventHandler PluginInstalled; - /// /// Gets the completed installations. /// diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index 397a68ff7e..c5e50cf45d 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -56,7 +56,7 @@ namespace MediaBrowser.Controller.Entities.Audio { if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { typeof(Audio).Name, typeof(MusicVideo).Name, typeof(MusicAlbum).Name }; + query.IncludeItemTypes = new[] { nameof(Audio), nameof(MusicVideo), nameof(MusicAlbum) }; query.ArtistIds = new[] { Id }; } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index 5a117a6b15..f0c076108e 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -64,7 +64,7 @@ namespace MediaBrowser.Controller.Entities.Audio public IList GetTaggedItems(InternalItemsQuery query) { query.GenreIds = new[] { Id }; - query.IncludeItemTypes = new[] { typeof(MusicVideo).Name, typeof(Audio).Name, typeof(MusicAlbum).Name, typeof(MusicArtist).Name }; + query.IncludeItemTypes = new[] { nameof(MusicVideo), nameof(Audio), nameof(MusicAlbum), nameof(MusicArtist) }; return LibraryManager.GetItemList(query); } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 2fc7d45c90..1d44a55114 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -87,6 +87,8 @@ namespace MediaBrowser.Controller.Entities public const string InterviewFolderName = "interviews"; public const string SceneFolderName = "scenes"; public const string SampleFolderName = "samples"; + public const string ShortsFolderName = "shorts"; + public const string FeaturettesFolderName = "featurettes"; public static readonly string[] AllExtrasTypesFolderNames = { ExtrasFolderName, @@ -94,7 +96,9 @@ namespace MediaBrowser.Controller.Entities DeletedScenesFolderName, InterviewFolderName, SceneFolderName, - SampleFolderName + SampleFolderName, + ShortsFolderName, + FeaturettesFolderName }; [JsonIgnore] diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 35ddaffadc..a76c8a376b 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -723,7 +723,7 @@ namespace MediaBrowser.Controller.Entities private bool RequiresPostFiltering2(InternalItemsQuery query) { - if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], typeof(BoxSet).Name, StringComparison.OrdinalIgnoreCase)) + if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)) { Logger.LogDebug("Query requires post-filtering due to BoxSet query"); return true; @@ -813,7 +813,7 @@ namespace MediaBrowser.Controller.Entities if (query.IsPlayed.HasValue) { - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(typeof(Series).Name)) + if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(nameof(Series))) { Logger.LogDebug("Query requires post-filtering due to IsPlayed"); return true; diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs index db6c85caf7..74a1702040 100644 --- a/MediaBrowser.Controller/Entities/Genre.cs +++ b/MediaBrowser.Controller/Entities/Genre.cs @@ -59,7 +59,13 @@ namespace MediaBrowser.Controller.Entities public IList GetTaggedItems(InternalItemsQuery query) { query.GenreIds = new[] { Id }; - query.ExcludeItemTypes = new[] { typeof(MusicVideo).Name, typeof(Audio.Audio).Name, typeof(MusicAlbum).Name, typeof(MusicArtist).Name }; + query.ExcludeItemTypes = new[] + { + nameof(MusicVideo), + nameof(Entities.Audio.Audio), + nameof(MusicAlbum), + nameof(MusicArtist) + }; return LibraryManager.GetItemList(query); } diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index 4e09ee5736..5b96a5af65 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System; +using Jellyfin.Data.Entities; namespace MediaBrowser.Controller.Entities { @@ -23,6 +24,10 @@ namespace MediaBrowser.Controller.Entities public string NameContains { get; set; } + public User User { get; set; } + + public bool? IsFavorite { get; set; } + public InternalPeopleQuery() { PersonTypes = Array.Empty(); diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 75a746bfb3..e8afa9a490 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -151,7 +151,7 @@ namespace MediaBrowser.Controller.Entities.TV if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; } query.IsVirtualItem = false; @@ -207,7 +207,7 @@ namespace MediaBrowser.Controller.Entities.TV query.AncestorWithPresentationUniqueKey = null; query.SeriesPresentationUniqueKey = seriesKey; - query.IncludeItemTypes = new[] { typeof(Season).Name }; + query.IncludeItemTypes = new[] { nameof(Season) }; query.OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray(); if (user != null && !user.DisplayMissingEpisodes) @@ -233,7 +233,7 @@ namespace MediaBrowser.Controller.Entities.TV if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { typeof(Episode).Name, typeof(Season).Name }; + query.IncludeItemTypes = new[] { nameof(Episode), nameof(Season) }; } query.IsVirtualItem = false; @@ -253,7 +253,7 @@ namespace MediaBrowser.Controller.Entities.TV { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { typeof(Episode).Name, typeof(Season).Name }, + IncludeItemTypes = new[] { nameof(Episode), nameof(Season) }, OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray(), DtoOptions = options }; @@ -364,7 +364,7 @@ namespace MediaBrowser.Controller.Entities.TV { AncestorWithPresentationUniqueKey = queryFromSeries ? null : seriesKey, SeriesPresentationUniqueKey = queryFromSeries ? seriesKey : null, - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray(), DtoOptions = options }; diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 7bb311900e..a262fee15d 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -142,7 +142,7 @@ namespace MediaBrowser.Controller.Entities if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; } return parent.QueryRecursive(query); @@ -167,7 +167,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return _libraryManager.GetItemsResult(query); } @@ -178,7 +178,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; return _libraryManager.GetItemsResult(query); } @@ -189,7 +189,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; return _libraryManager.GetItemsResult(query); } @@ -200,7 +200,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return _libraryManager.GetItemsResult(query); } @@ -208,7 +208,7 @@ namespace MediaBrowser.Controller.Entities private QueryResult GetMovieCollections(Folder parent, User user, InternalItemsQuery query) { query.Parent = null; - query.IncludeItemTypes = new[] { typeof(BoxSet).Name }; + query.IncludeItemTypes = new[] { nameof(BoxSet) }; query.SetUser(user); query.Recursive = true; @@ -223,7 +223,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -236,7 +236,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -255,7 +255,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Movie).Name }, + IncludeItemTypes = new[] { nameof(Movie) }, Recursive = true, EnableTotalRecordCount = false }).Items @@ -286,7 +286,7 @@ namespace MediaBrowser.Controller.Entities query.GenreIds = new[] { displayParent.Id }; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return _libraryManager.GetItemsResult(query); } @@ -333,7 +333,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; query.IsVirtualItem = false; return ConvertToResult(_libraryManager.GetItemList(query)); @@ -362,7 +362,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -373,7 +373,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; return _libraryManager.GetItemsResult(query); } @@ -382,7 +382,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Series).Name }, + IncludeItemTypes = new[] { nameof(Series) }, Recursive = true, EnableTotalRecordCount = false }).Items @@ -413,7 +413,7 @@ namespace MediaBrowser.Controller.Entities query.GenreIds = new[] { displayParent.Id }; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; return _libraryManager.GetItemsResult(query); } diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs index b35f830960..6658269bdb 100644 --- a/MediaBrowser.Controller/IDisplayPreferencesManager.cs +++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs @@ -12,6 +12,9 @@ namespace MediaBrowser.Controller /// /// Gets the display preferences for the user and client. /// + /// + /// This will create the display preferences if it does not exist, but it will not save automatically. + /// /// The user's id. /// The client string. /// The associated display preferences. @@ -20,6 +23,9 @@ namespace MediaBrowser.Controller /// /// Gets the default item display preferences for the user and client. /// + /// + /// This will create the item display preferences if it does not exist, but it will not save automatically. + /// /// The user id. /// The item id. /// The client string. diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index cfad17fb72..ffbb147b0d 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -6,8 +6,8 @@ using System.Net; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; +using MediaBrowser.Common.Plugins; using MediaBrowser.Model.System; -using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller { @@ -56,10 +56,11 @@ namespace MediaBrowser.Controller /// /// Gets the system info. /// + /// A cancellation token that can be used to cancel the task. /// SystemInfo. - Task GetSystemInfo(CancellationToken cancellationToken); + Task GetSystemInfo(CancellationToken cancellationToken = default); - Task GetPublicSystemInfo(CancellationToken cancellationToken); + Task GetPublicSystemInfo(CancellationToken cancellationToken = default); /// /// Gets all the local IP addresses of this API instance. Each address is validated by sending a 'ping' request @@ -67,7 +68,7 @@ namespace MediaBrowser.Controller /// /// A cancellation token that can be used to cancel the task. /// A list containing all the local IP addresses of the server. - Task> GetLocalIpAddresses(CancellationToken cancellationToken); + Task> GetLocalIpAddresses(CancellationToken cancellationToken = default); /// /// Gets a local (LAN) URL that can be used to access the API. The hostname used is the first valid configured @@ -75,7 +76,7 @@ namespace MediaBrowser.Controller /// /// A cancellation token that can be used to cancel the task. /// The server URL. - Task GetLocalApiUrl(CancellationToken cancellationToken); + Task GetLocalApiUrl(CancellationToken cancellationToken = default); /// /// Gets a localhost URL that can be used to access the API using the loop-back IP address (127.0.0.1) @@ -119,5 +120,13 @@ namespace MediaBrowser.Controller string ExpandVirtualPath(string path); string ReverseVirtualPath(string path); + + /// + /// Gets the list of local plugins. + /// + /// Plugin base directory. + /// Cleanup old plugins. + /// Enumerable of local plugins. + IEnumerable GetLocalPlugins(string path, bool cleanup = true); } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 32703c2fd1..c7c79df76d 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -570,5 +570,7 @@ namespace MediaBrowser.Controller.Library List streams, string videoPath, string[] files); + + BaseItem GetParentItem(string parentId, Guid? userId); } } diff --git a/MediaBrowser.Controller/Library/IMediaSourceManager.cs b/MediaBrowser.Controller/Library/IMediaSourceManager.cs index 22bf9488f7..21c6ef2af1 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs @@ -115,5 +115,7 @@ namespace MediaBrowser.Controller.Library public interface IDirectStreamProvider { Task CopyToAsync(Stream stream, CancellationToken cancellationToken); + + string GetFilePath(); } } diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 6a4f5cf679..8fd3b8c347 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -158,7 +158,8 @@ namespace MediaBrowser.Controller.Library /// /// The user's Id. /// The request containing the new user configuration. - void UpdateConfiguration(Guid userId, UserConfiguration config); + /// A task representing the update. + Task UpdateConfigurationAsync(Guid userId, UserConfiguration config); /// /// This method updates the user's policy. @@ -167,12 +168,14 @@ namespace MediaBrowser.Controller.Library /// /// The user's Id. /// The request containing the new user policy. - void UpdatePolicy(Guid userId, UserPolicy policy); + /// A task representing the update. + Task UpdatePolicyAsync(Guid userId, UserPolicy policy); /// /// Clears the user's profile image. /// /// The user. - void ClearProfileImage(User user); + /// A task representing the clearing of the profile image. + Task ClearProfileImageAsync(User user); } } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 61340cd242..5846a603a2 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1380,24 +1380,40 @@ namespace MediaBrowser.Controller.MediaEncoding public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream) { + if (audioStream == null) + { + return null; + } + if (request.AudioBitRate.HasValue) { // Don't encode any higher than this return Math.Min(384000, request.AudioBitRate.Value); } - return null; + // Empty bitrate area is not allow on iOS + // Default audio bitrate to 128K if it is not being requested + // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options + return 128000; } public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream) { + if (audioStream == null) + { + return null; + } + if (audioBitRate.HasValue) { // Don't encode any higher than this return Math.Min(384000, audioBitRate.Value); } - return null; + // Empty bitrate area is not allow on iOS + // Default audio bitrate to 128K if it is not being requested + // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options + return 128000; } public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions, bool isHls) @@ -2659,9 +2675,10 @@ namespace MediaBrowser.Controller.MediaEncoding state.MediaSource = mediaSource; var request = state.BaseRequest; - if (!string.IsNullOrWhiteSpace(request.AudioCodec)) + var supportedAudioCodecs = state.SupportedAudioCodecs; + if (request != null && supportedAudioCodecs != null && supportedAudioCodecs.Length > 0) { - var supportedAudioCodecsList = request.AudioCodec.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); + var supportedAudioCodecsList = supportedAudioCodecs.ToList(); ShiftAudioCodecsIfNeeded(supportedAudioCodecsList, state.AudioStream); @@ -3068,7 +3085,7 @@ namespace MediaBrowser.Controller.MediaEncoding } } - var whichCodec = videoStream.Codec.ToLowerInvariant(); + var whichCodec = videoStream.Codec?.ToLowerInvariant(); switch (whichCodec) { case "avc": diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 6cd0c70d2f..6e9362cd14 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -287,6 +287,11 @@ namespace MediaBrowser.Controller.MediaEncoding return BaseRequest.AudioChannels; } + if (BaseRequest.TranscodingMaxAudioChannels.HasValue) + { + return BaseRequest.TranscodingMaxAudioChannels; + } + if (!string.IsNullOrEmpty(codec)) { var value = BaseRequest.GetOption(codec, "audiochannels"); diff --git a/MediaBrowser.Controller/Net/AuthorizationInfo.cs b/MediaBrowser.Controller/Net/AuthorizationInfo.cs index 735c46ef86..0194c596f1 100644 --- a/MediaBrowser.Controller/Net/AuthorizationInfo.cs +++ b/MediaBrowser.Controller/Net/AuthorizationInfo.cs @@ -1,10 +1,11 @@ -#pragma warning disable CS1591 - using System; using Jellyfin.Data.Entities; namespace MediaBrowser.Controller.Net { + /// + /// The request authorization info. + /// public class AuthorizationInfo { /// @@ -43,6 +44,19 @@ namespace MediaBrowser.Controller.Net /// The token. public string Token { get; set; } + /// + /// Gets or sets a value indicating whether the authorization is from an api key. + /// + public bool IsApiKey { get; set; } + + /// + /// Gets or sets the user making the request. + /// public User User { get; set; } + + /// + /// Gets or sets a value indicating whether the token is authenticated. + /// + public bool IsAuthenticated { get; set; } } } diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index 216dd27098..e8b7be7e20 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -160,7 +160,7 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, GenreIds = new[] { musicGenre.Id }, OrderBy = new[] { ItemSortBy.AlbumArtist, ItemSortBy.Album, ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray(), DtoOptions = options @@ -172,7 +172,7 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, ArtistIds = new[] { musicArtist.Id }, OrderBy = new[] { ItemSortBy.AlbumArtist, ItemSortBy.Album, ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray(), DtoOptions = options diff --git a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs index f43d523a63..feb26bc101 100644 --- a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs +++ b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -52,6 +53,14 @@ namespace MediaBrowser.Controller.Subtitles /// Task DownloadSubtitles(Video video, LibraryOptions libraryOptions, string subtitleId, CancellationToken cancellationToken); + /// + /// Upload new subtitle. + /// + /// The video the subtitle belongs to. + /// The subtitle response. + /// A representing the asynchronous operation. + Task UploadSubtitle(Video video, SubtitleResponse response); + /// /// Gets the remote subtitles. /// diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 22537a4d95..cdeefbbbdf 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -666,6 +666,16 @@ namespace MediaBrowser.MediaEncoding.Probing stream.AverageFrameRate = GetFrameRate(streamInfo.AverageFrameRate); stream.RealFrameRate = GetFrameRate(streamInfo.RFrameRate); + // Interlaced video streams in Matroska containers return the field rate instead of the frame rate + // as both the average and real frame rate, so we half the returned frame rates to get the correct values + // + // https://gitlab.com/mbunkus/mkvtoolnix/-/wikis/Wrong-frame-rate-displayed + if (stream.IsInterlaced && formatInfo.FormatName.Contains("matroska", StringComparison.OrdinalIgnoreCase)) + { + stream.AverageFrameRate /= 2; + stream.RealFrameRate /= 2; + } + if (isAudio || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)) { diff --git a/MediaBrowser.Model/Activity/IActivityManager.cs b/MediaBrowser.Model/Activity/IActivityManager.cs index 3e4ea208ed..28073fb8d7 100644 --- a/MediaBrowser.Model/Activity/IActivityManager.cs +++ b/MediaBrowser.Model/Activity/IActivityManager.cs @@ -16,5 +16,12 @@ namespace MediaBrowser.Model.Activity Task CreateAsync(ActivityLog entry); Task> GetPagedResultAsync(ActivityLogQuery query); + + /// + /// Remove all activity logs before the specified date. + /// + /// Activity log start date. + /// A representing the asynchronous operation. + Task CleanAsync(DateTime startDate); } } diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 2cd637c5b0..c348256677 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -9,6 +9,10 @@ namespace MediaBrowser.Model.Configuration public string TranscodingTempPath { get; set; } + public string FallbackFontPath { get; set; } + + public bool EnableFallbackFont { get; set; } + public double DownMixAudioBoost { get; set; } public int MaxMuxingQueueSize { get; set; } @@ -69,6 +73,7 @@ namespace MediaBrowser.Model.Configuration public EncodingOptions() { + EnableFallbackFont = false; DownMixAudioBoost = 2; MaxMuxingQueueSize = 2048; EnableThrottling = false; diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 54ef49ea62..77ac11d69f 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -17,8 +17,6 @@ namespace MediaBrowser.Model.Configuration public bool ExtractChapterImagesDuringLibraryScan { get; set; } - public bool DownloadImagesInAdvance { get; set; } - public MediaPathInfo[] PathInfos { get; set; } public bool SaveLocalMetadata { get; set; } diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 8b78ad842e..23a5201f7a 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -271,6 +271,11 @@ namespace MediaBrowser.Model.Configuration /// public string[] KnownProxies { get; set; } + /// + /// Gets or sets the number of days we should retain activity logs. + /// + public int? ActivityLogRetentionDays { get; set; } + /// /// Initializes a new instance of the class. /// @@ -381,6 +386,7 @@ namespace MediaBrowser.Model.Configuration SlowResponseThresholdMs = 500; CorsHosts = new[] { "*" }; KnownProxies = Array.Empty(); + ActivityLogRetentionDays = 30; } } diff --git a/MediaBrowser.Model/Dlna/AudioOptions.cs b/MediaBrowser.Model/Dlna/AudioOptions.cs index 67e4ffe03e..bbb8bf4263 100644 --- a/MediaBrowser.Model/Dlna/AudioOptions.cs +++ b/MediaBrowser.Model/Dlna/AudioOptions.cs @@ -49,7 +49,7 @@ namespace MediaBrowser.Model.Dlna /// /// The application's configured quality setting. /// - public long? MaxBitrate { get; set; } + public int? MaxBitrate { get; set; } /// /// Gets or sets the context. @@ -67,7 +67,7 @@ namespace MediaBrowser.Model.Dlna /// Gets the maximum bitrate. /// /// System.Nullable<System.Int32>. - public long? GetMaxBitrate(bool isAudio) + public int? GetMaxBitrate(bool isAudio) { if (MaxBitrate.HasValue) { diff --git a/MediaBrowser.Model/Dlna/DeviceProfile.cs b/MediaBrowser.Model/Dlna/DeviceProfile.cs index 8b2a94fd45..ff51866587 100644 --- a/MediaBrowser.Model/Dlna/DeviceProfile.cs +++ b/MediaBrowser.Model/Dlna/DeviceProfile.cs @@ -142,12 +142,12 @@ namespace MediaBrowser.Model.Dlna /// /// Gets or sets the MaxStreamingBitrate. /// - public long? MaxStreamingBitrate { get; set; } + public int? MaxStreamingBitrate { get; set; } /// /// Gets or sets the MaxStaticBitrate. /// - public long? MaxStaticBitrate { get; set; } + public int? MaxStaticBitrate { get; set; } /// /// Gets or sets the MusicStreamingTranscodingBitrate. diff --git a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs index 83bda5d569..a8ea405e28 100644 --- a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs +++ b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs @@ -37,7 +37,7 @@ namespace MediaBrowser.Model.MediaInfo public string PlaySessionId { get; set; } - public long? MaxStreamingBitrate { get; set; } + public int? MaxStreamingBitrate { get; set; } public long? StartTimeTicks { get; set; } diff --git a/MediaBrowser.Model/Subtitles/FontFile.cs b/MediaBrowser.Model/Subtitles/FontFile.cs new file mode 100644 index 0000000000..115c492957 --- /dev/null +++ b/MediaBrowser.Model/Subtitles/FontFile.cs @@ -0,0 +1,34 @@ +using System; + +namespace MediaBrowser.Model.Subtitles +{ + /// + /// Class FontFile. + /// + public class FontFile + { + /// + /// Gets or sets the name. + /// + /// The name. + public string? Name { get; set; } + + /// + /// Gets or sets the size. + /// + /// The size. + public long Size { get; set; } + + /// + /// Gets or sets the date created. + /// + /// The date created. + public DateTime DateCreated { get; set; } + + /// + /// Gets or sets the date modified. + /// + /// The date modified. + public DateTime DateModified { get; set; } + } +} diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index d0bdbd7c95..a57a85376e 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -517,13 +517,8 @@ namespace MediaBrowser.Providers.Manager return true; } } - - if (libraryOptions.DownloadImagesInAdvance) - { - return false; - } - - return true; + // We always want to use prefetched images + return false; } private void SaveImageStub(BaseItem item, ImageType imageType, IEnumerable urls) diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index f110eafa5a..dca8acb7d8 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -252,7 +252,13 @@ namespace MediaBrowser.Providers.Manager if (!string.IsNullOrWhiteSpace(person.ImageUrl) && !personEntity.HasImage(ImageType.Primary)) { - await AddPersonImageAsync(personEntity, libraryOptions, person.ImageUrl, cancellationToken).ConfigureAwait(false); + personEntity.SetImage( + new ItemImageInfo + { + Path = person.ImageUrl, + Type = ImageType.Primary + }, + 0); saveEntity = true; updateType |= ItemUpdateType.ImageUpdate; @@ -266,30 +272,6 @@ namespace MediaBrowser.Providers.Manager } } - private async Task AddPersonImageAsync(Person personEntity, LibraryOptions libraryOptions, string imageUrl, CancellationToken cancellationToken) - { - if (libraryOptions.DownloadImagesInAdvance) - { - try - { - await ProviderManager.SaveImage(personEntity, imageUrl, ImageType.Primary, null, cancellationToken).ConfigureAwait(false); - return; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error in AddPersonImage"); - } - } - - personEntity.SetImage( - new ItemImageInfo - { - Path = imageUrl, - Type = ImageType.Primary - }, - 0); - } - protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) { item.AfterMetadataRefresh(); diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 24400eae53..9465fe42c2 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -19,7 +19,7 @@ - + diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs index dc3c60dee0..a5cd425f6d 100644 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs @@ -54,7 +54,7 @@ namespace MediaBrowser.Providers.Plugins.TheTvdb { var seriesWithPerson = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Series).Name }, + IncludeItemTypes = new[] { nameof(Series) }, PersonIds = new[] { item.Id }, DtoOptions = new DtoOptions(false) { diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index f25d3d5ee7..f3fbe2d12d 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -150,37 +150,11 @@ namespace MediaBrowser.Providers.Subtitles var parts = subtitleId.Split(new[] { '_' }, 2); var provider = GetProvider(parts[0]); - var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia; - try { var response = await GetRemoteSubtitles(subtitleId, cancellationToken).ConfigureAwait(false); - using (var stream = response.Stream) - using (var memoryStream = new MemoryStream()) - { - await stream.CopyToAsync(memoryStream).ConfigureAwait(false); - memoryStream.Position = 0; - - var savePaths = new List(); - var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); - - if (response.IsForced) - { - saveFileName += ".forced"; - } - - saveFileName += "." + response.Format.ToLowerInvariant(); - - if (saveInMediaFolder) - { - savePaths.Add(Path.Combine(video.ContainingFolderPath, saveFileName)); - } - - savePaths.Add(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); - - await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); - } + await TrySaveSubtitle(video, libraryOptions, response).ConfigureAwait(false); } catch (RateLimitExceededException) { @@ -199,6 +173,47 @@ namespace MediaBrowser.Providers.Subtitles } } + /// + public Task UploadSubtitle(Video video, SubtitleResponse response) + { + var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video); + return TrySaveSubtitle(video, libraryOptions, response); + } + + private async Task TrySaveSubtitle( + Video video, + LibraryOptions libraryOptions, + SubtitleResponse response) + { + var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia; + + using (var stream = response.Stream) + using (var memoryStream = new MemoryStream()) + { + await stream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; + + var savePaths = new List(); + var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); + + if (response.IsForced) + { + saveFileName += ".forced"; + } + + saveFileName += "." + response.Format.ToLowerInvariant(); + + if (saveInMediaFolder) + { + savePaths.Add(Path.Combine(video.ContainingFolderPath, saveFileName)); + } + + savePaths.Add(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); + + await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); + } + } + private async Task TrySaveToFiles(Stream stream, List savePaths) { Exception exceptionToThrow = null; diff --git a/apiclient/templates/typescript/axios/generate.sh b/apiclient/templates/typescript/axios/generate.sh index 8c4d742825..9599f85dbd 100644 --- a/apiclient/templates/typescript/axios/generate.sh +++ b/apiclient/templates/typescript/axios/generate.sh @@ -1,14 +1,6 @@ #!/bin/bash artifactsDirectory="${1}" -buildNumber="${2}" -if [[ -n ${buildNumber} ]]; then - # Unstable build - additionalProperties=",snapshotVersion=-SNAPSHOT.${buildNumber},npmRepository=https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/npm/registry/" -else - # Stable build - additionalProperties="" -fi java -jar openapi-generator-cli.jar generate \ --input-spec ${artifactsDirectory}/openapispec/openapi.json \ @@ -16,4 +8,4 @@ java -jar openapi-generator-cli.jar generate \ --output ./apiclient/generated/typescript/axios \ --template-dir ./apiclient/templates/typescript/axios \ --ignore-file-override ./apiclient/.openapi-generator-ignore \ - --additional-properties=useSingleRequestParameter="true",withSeparateModelsAndApi="true",modelPackage="models",apiPackage="api",npmName="axios"${additionalProperties} + --additional-properties=useSingleRequestParameter="true",withSeparateModelsAndApi="true",modelPackage="models",apiPackage="api",npmName="axios" diff --git a/debian/control b/debian/control index 39c2aa055b..9216d24fe3 100644 --- a/debian/control +++ b/debian/control @@ -20,7 +20,6 @@ Breaks: jellyfin (<<10.6.0) Architecture: any Depends: at, libsqlite3-0, - jellyfin-ffmpeg (>= 4.2.1-2), libfontconfig1, libfreetype6, libssl1.1 diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs index 4ea5094b66..a46d94457f 100644 --- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -8,6 +8,7 @@ using Jellyfin.Api.Auth; using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -68,14 +69,14 @@ namespace Jellyfin.Api.Tests.Auth } [Fact] - public async Task HandleAuthenticateAsyncShouldFailOnSecurityException() + public async Task HandleAuthenticateAsyncShouldFailOnAuthenticationException() { var errorMessage = _fixture.Create(); _jellyfinAuthServiceMock.Setup( a => a.Authenticate( It.IsAny())) - .Throws(new SecurityException(errorMessage)); + .Throws(new AuthenticationException(errorMessage)); var authenticateResult = await _sut.AuthenticateAsync(); @@ -128,6 +129,7 @@ namespace Jellyfin.Api.Tests.Auth var authorizationInfo = _fixture.Create(); authorizationInfo.User = _fixture.Create(); authorizationInfo.User.SetPermission(PermissionKind.IsAdministrator, isAdmin); + authorizationInfo.IsApiKey = false; _jellyfinAuthServiceMock.Setup( a => a.Authenticate( diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index aae436fb73..ce61f5684e 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -13,16 +13,16 @@ - - - + + + - + - + diff --git a/tests/Jellyfin.Api.Tests/TestHelpers.cs b/tests/Jellyfin.Api.Tests/TestHelpers.cs index a4dd4e4092..c4ce398859 100644 --- a/tests/Jellyfin.Api.Tests/TestHelpers.cs +++ b/tests/Jellyfin.Api.Tests/TestHelpers.cs @@ -45,7 +45,7 @@ namespace Jellyfin.Api.Tests { new Claim(ClaimTypes.Role, role), new Claim(ClaimTypes.Name, "jellyfin"), - new Claim(InternalClaimTypes.UserId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)), + new Claim(InternalClaimTypes.UserId, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)), new Claim(InternalClaimTypes.DeviceId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)), new Claim(InternalClaimTypes.Device, "test"), new Claim(InternalClaimTypes.Client, "test"), diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index e3f87d29b7..67dc8286a3 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs new file mode 100644 index 0000000000..0d2bdd1af9 --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Jellyfin.Common.Tests.Models; +using MediaBrowser.Model.Session; +using Xunit; + +namespace Jellyfin.Common.Tests.Json +{ + public static class JsonCommaDelimitedArrayTests + { + [Fact] + public static void Deserialize_String_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a,b,c"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_String_Space_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a, b, c"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Space_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_String_Array_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""a"",""b"",""c""] }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Array_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + } +} diff --git a/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs new file mode 100644 index 0000000000..34ad9bac79 --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Jellyfin.Common.Tests.Models; +using MediaBrowser.Model.Session; +using Xunit; + +namespace Jellyfin.Common.Tests.Json +{ + public static class JsonCommaDelimitedIReadOnlyListTests + { + [Fact] + public static void Deserialize_String_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a,b,c"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_String_Space_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a, b, c"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Space_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_String_Array_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""a"",""b"",""c""] }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Array_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + } +} diff --git a/tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs b/tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs new file mode 100644 index 0000000000..276e1bfbe2 --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using MediaBrowser.Common.Json.Converters; + +namespace Jellyfin.Common.Tests.Models +{ + /// + /// The generic body model. + /// + /// The value type. + public class GenericBodyArrayModel + { + /// + /// Gets or sets the value. + /// + [SuppressMessage("Microsoft.Performance", "CA1819:Properties should not return arrays", MessageId = "Value", Justification = "Imported from ServiceStack")] + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public T[] Value { get; set; } = default!; + } +} diff --git a/tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs b/tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs new file mode 100644 index 0000000000..627454b25f --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using MediaBrowser.Common.Json.Converters; + +namespace Jellyfin.Common.Tests.Models +{ + /// + /// The generic body IReadOnlyList model. + /// + /// The value type. + public class GenericBodyIReadOnlyListModel + { + /// + /// Gets or sets the value. + /// + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList Value { get; set; } = default!; + } +} diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index 5de02a29ba..30e84842a0 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index 3ac60819b4..4fd0d53421 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -19,7 +19,7 @@ - + diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index 37d0a9929a..0d240fd65a 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index 75b67f460f..db1f2956ea 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -14,10 +14,10 @@ - - - - + + + +