diff options
| author | Matt Montgomery <33811686+ConfusedPolarBear@users.noreply.github.com> | 2020-08-12 15:38:07 -0500 |
|---|---|---|
| committer | Matt Montgomery <33811686+ConfusedPolarBear@users.noreply.github.com> | 2020-08-12 15:38:07 -0500 |
| commit | 4fa3d3f4f3083a43622d69aa76ae714b7a7aabd7 (patch) | |
| tree | 4f2e3984788ae0b98c7f49abcd0d60374bfde16b | |
| parent | 31d3b1b83aa356221e8af2f316b58584579207fe (diff) | |
| parent | 741ab4301c6e7cb4b43da9b03732731efdd648a1 (diff) | |
Merge remote-tracking branch 'upstream/master' into quickconnect
389 files changed, 28361 insertions, 24254 deletions
diff --git a/.ci/azure-pipelines-abi.yml b/.ci/azure-pipelines-abi.yml index 635aa759c..b558d2a6f 100644 --- a/.ci/azure-pipelines-abi.yml +++ b/.ci/azure-pipelines-abi.yml @@ -12,10 +12,12 @@ parameters: jobs: - job: CompatibilityCheck displayName: Compatibility Check + dependsOn: Build + condition: and(succeeded(), variables['System.PullRequest.PullRequestNumber']) + pool: vmImage: "${{ parameters.LinuxImage }}" - # only execute for pull requests - condition: and(succeeded(), variables['System.PullRequest.PullRequestNumber']) + strategy: matrix: ${{ each Package in parameters.Packages }}: @@ -23,7 +25,7 @@ jobs: NugetPackageName: ${{ Package.value.NugetPackageName }} AssemblyFileName: ${{ Package.value.AssemblyFileName }} maxParallel: 2 - dependsOn: Build + steps: - checkout: none @@ -34,32 +36,33 @@ jobs: version: ${{ parameters.DotNetSdkVersion }} - task: DotNetCoreCLI@2 - displayName: 'Install ABI CompatibilityChecker tool' + displayName: 'Install ABI CompatibilityChecker Tool' inputs: command: custom custom: tool arguments: 'update compatibilitychecker -g' - task: DownloadPipelineArtifact@2 - displayName: "Download New Assembly Build Artifact" + displayName: 'Download New Assembly Build Artifact' inputs: - source: "current" + source: 'current' artifact: "$(NugetPackageName)" path: "$(System.ArtifactsDirectory)/new-artifacts" runVersion: "latest" - task: CopyFiles@2 - displayName: "Copy New Assembly Build Artifact" + displayName: 'Copy New Assembly Build Artifact' inputs: sourceFolder: $(System.ArtifactsDirectory)/new-artifacts - contents: "**/*.dll" + contents: '**/*.dll' targetFolder: $(System.ArtifactsDirectory)/new-release cleanTargetFolder: true overWrite: true flattenFolders: true - task: DownloadPipelineArtifact@2 - displayName: "Download Reference Assembly Build Artifact" + displayName: 'Download Reference Assembly Build Artifact' + enabled: false inputs: source: "specific" artifact: "$(NugetPackageName)" @@ -70,18 +73,19 @@ jobs: runBranch: "refs/heads/$(System.PullRequest.TargetBranch)" - task: CopyFiles@2 - displayName: "Copy Reference Assembly Build Artifact" + displayName: 'Copy Reference Assembly Build Artifact' + enabled: false inputs: sourceFolder: $(System.ArtifactsDirectory)/current-artifacts - contents: "**/*.dll" + contents: '**/*.dll' targetFolder: $(System.ArtifactsDirectory)/current-release cleanTargetFolder: true overWrite: true flattenFolders: true - # The `--warnings-only` switch will swallow the return code and not emit any errors. - task: DotNetCoreCLI@2 displayName: 'Execute ABI Compatibility Check Tool' + enabled: false inputs: command: custom custom: compat diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 272111299..003d5baf0 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -80,7 +80,15 @@ jobs: pool: vmImage: 'ubuntu-latest' + variables: + - name: JellyfinVersion + value: 0.0.0 + steps: + - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )" + displayName: Set release version (stable) + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + - task: Docker@2 displayName: 'Push Unstable Image' condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') @@ -105,9 +113,10 @@ jobs: containerRegistry: Docker Hub tags: | stable-$(Build.BuildNumber)-$(BuildConfiguration) - stable-$(BuildConfiguration) + $(JellyfinVersion)-$(BuildConfiguration) - job: CollectArtifacts + timeoutInMinutes: 10 displayName: 'Collect Artifacts' dependsOn: - BuildPackage @@ -123,29 +132,23 @@ jobs: condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') inputs: sshEndpoint: repository - runOptions: 'inline' - inline: | - sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable - rm $0 - exit + runOptions: 'commands' + commands: sudo -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable - task: SSH@0 displayName: 'Update Stable Repository' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') inputs: sshEndpoint: repository - runOptions: 'inline' - inline: | - sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) - rm $0 - exit - + runOptions: 'commands' + commands: sudo -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) + - job: PublishNuget displayName: 'Publish NuGet packages' dependsOn: - BuildPackage condition: and(succeeded('BuildPackage'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) - + pool: vmImage: 'ubuntu-latest' diff --git a/.vscode/launch.json b/.vscode/launch.json index 0f698bfa4..bf1bd65cb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,11 +6,21 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - // If you have changed target frameworks, make sure to update the program path. "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll", "args": [], "cwd": "${workspaceFolder}/Jellyfin.Server", - // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window + "console": "internalConsole", + "stopAtEntry": false, + "internalConsoleOptions": "openOnSessionStart" + }, + { + "name": ".NET Core Launch (nowebclient)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll", + "args": ["--nowebclient"], + "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", "stopAtEntry": false, "internalConsoleOptions": "openOnSessionStart" diff --git a/Emby.Dlna/Api/DlnaServerService.cs b/Emby.Dlna/Api/DlnaServerService.cs deleted file mode 100644 index d9c1669b0..000000000 --- a/Emby.Dlna/Api/DlnaServerService.cs +++ /dev/null @@ -1,386 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Text; -using System.Threading.Tasks; -using Emby.Dlna.Main; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; -using Microsoft.AspNetCore.Http; - -namespace Emby.Dlna.Api -{ - [Route("/Dlna/{UuId}/description.xml", "GET", Summary = "Gets dlna server info")] - [Route("/Dlna/{UuId}/description", "GET", Summary = "Gets dlna server info")] - public class GetDescriptionXml - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")] - public string UuId { get; set; } - } - - [Route("/Dlna/{UuId}/contentdirectory/contentdirectory.xml", "GET", Summary = "Gets dlna content directory xml")] - [Route("/Dlna/{UuId}/contentdirectory/contentdirectory", "GET", Summary = "Gets dlna content directory xml")] - public class GetContentDirectory - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")] - public string UuId { get; set; } - } - - [Route("/Dlna/{UuId}/connectionmanager/connectionmanager.xml", "GET", Summary = "Gets dlna connection manager xml")] - [Route("/Dlna/{UuId}/connectionmanager/connectionmanager", "GET", Summary = "Gets dlna connection manager xml")] - public class GetConnnectionManager - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")] - public string UuId { get; set; } - } - - [Route("/Dlna/{UuId}/mediareceiverregistrar/mediareceiverregistrar.xml", "GET", Summary = "Gets dlna mediareceiverregistrar xml")] - [Route("/Dlna/{UuId}/mediareceiverregistrar/mediareceiverregistrar", "GET", Summary = "Gets dlna mediareceiverregistrar xml")] - public class GetMediaReceiverRegistrar - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")] - public string UuId { get; set; } - } - - [Route("/Dlna/{UuId}/contentdirectory/control", "POST", Summary = "Processes a control request")] - public class ProcessContentDirectoryControlRequest : IRequiresRequestStream - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")] - public string UuId { get; set; } - - public Stream RequestStream { get; set; } - } - - [Route("/Dlna/{UuId}/connectionmanager/control", "POST", Summary = "Processes a control request")] - public class ProcessConnectionManagerControlRequest : IRequiresRequestStream - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")] - public string UuId { get; set; } - - public Stream RequestStream { get; set; } - } - - [Route("/Dlna/{UuId}/mediareceiverregistrar/control", "POST", Summary = "Processes a control request")] - public class ProcessMediaReceiverRegistrarControlRequest : IRequiresRequestStream - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")] - public string UuId { get; set; } - - public Stream RequestStream { get; set; } - } - - [Route("/Dlna/{UuId}/mediareceiverregistrar/events", "SUBSCRIBE", Summary = "Processes an event subscription request")] - [Route("/Dlna/{UuId}/mediareceiverregistrar/events", "UNSUBSCRIBE", Summary = "Processes an event subscription request")] - public class ProcessMediaReceiverRegistrarEventRequest - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "SUBSCRIBE,UNSUBSCRIBE")] - public string UuId { get; set; } - } - - [Route("/Dlna/{UuId}/contentdirectory/events", "SUBSCRIBE", Summary = "Processes an event subscription request")] - [Route("/Dlna/{UuId}/contentdirectory/events", "UNSUBSCRIBE", Summary = "Processes an event subscription request")] - public class ProcessContentDirectoryEventRequest - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "SUBSCRIBE,UNSUBSCRIBE")] - public string UuId { get; set; } - } - - [Route("/Dlna/{UuId}/connectionmanager/events", "SUBSCRIBE", Summary = "Processes an event subscription request")] - [Route("/Dlna/{UuId}/connectionmanager/events", "UNSUBSCRIBE", Summary = "Processes an event subscription request")] - public class ProcessConnectionManagerEventRequest - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "SUBSCRIBE,UNSUBSCRIBE")] - public string UuId { get; set; } - } - - [Route("/Dlna/{UuId}/icons/{Filename}", "GET", Summary = "Gets a server icon")] - [Route("/Dlna/icons/{Filename}", "GET", Summary = "Gets a server icon")] - public class GetIcon - { - [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string UuId { get; set; } - - [ApiMember(Name = "Filename", Description = "The icon filename", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Filename { get; set; } - } - - public class DlnaServerService : IService - { - private const string XMLContentType = "text/xml; charset=UTF-8"; - - private readonly IDlnaManager _dlnaManager; - private readonly IHttpResultFactory _resultFactory; - private readonly IServerConfigurationManager _configurationManager; - - public IRequest Request { get; set; } - - private IContentDirectory ContentDirectory => DlnaEntryPoint.Current.ContentDirectory; - - private IConnectionManager ConnectionManager => DlnaEntryPoint.Current.ConnectionManager; - - private IMediaReceiverRegistrar MediaReceiverRegistrar => DlnaEntryPoint.Current.MediaReceiverRegistrar; - - public DlnaServerService( - IDlnaManager dlnaManager, - IHttpResultFactory httpResultFactory, - IServerConfigurationManager configurationManager, - IHttpContextAccessor httpContextAccessor) - { - _dlnaManager = dlnaManager; - _resultFactory = httpResultFactory; - _configurationManager = configurationManager; - Request = httpContextAccessor?.HttpContext.GetServiceStackRequest() ?? throw new ArgumentNullException(nameof(httpContextAccessor)); - } - - private string GetHeader(string name) - { - return Request.Headers[name]; - } - - public object Get(GetDescriptionXml request) - { - var url = Request.AbsoluteUri; - var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase)); - var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, request.UuId, serverAddress); - - var cacheLength = TimeSpan.FromDays(1); - var cacheKey = Request.RawUrl.GetMD5(); - var bytes = Encoding.UTF8.GetBytes(xml); - - return _resultFactory.GetStaticResult(Request, cacheKey, null, cacheLength, XMLContentType, () => Task.FromResult<Stream>(new MemoryStream(bytes))); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetContentDirectory request) - { - var xml = ContentDirectory.GetServiceXml(); - - return _resultFactory.GetResult(Request, xml, XMLContentType); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetMediaReceiverRegistrar request) - { - var xml = MediaReceiverRegistrar.GetServiceXml(); - - return _resultFactory.GetResult(Request, xml, XMLContentType); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetConnnectionManager request) - { - var xml = ConnectionManager.GetServiceXml(); - - return _resultFactory.GetResult(Request, xml, XMLContentType); - } - - public async Task<object> Post(ProcessMediaReceiverRegistrarControlRequest request) - { - var response = await PostAsync(request.RequestStream, MediaReceiverRegistrar).ConfigureAwait(false); - - return _resultFactory.GetResult(Request, response.Xml, XMLContentType); - } - - public async Task<object> Post(ProcessContentDirectoryControlRequest request) - { - var response = await PostAsync(request.RequestStream, ContentDirectory).ConfigureAwait(false); - - return _resultFactory.GetResult(Request, response.Xml, XMLContentType); - } - - public async Task<object> Post(ProcessConnectionManagerControlRequest request) - { - var response = await PostAsync(request.RequestStream, ConnectionManager).ConfigureAwait(false); - - return _resultFactory.GetResult(Request, response.Xml, XMLContentType); - } - - private Task<ControlResponse> PostAsync(Stream requestStream, IUpnpService service) - { - var id = GetPathValue(2).ToString(); - - return service.ProcessControlRequestAsync(new ControlRequest - { - Headers = Request.Headers, - InputXml = requestStream, - TargetServerUuId = id, - RequestedUrl = Request.AbsoluteUri - }); - } - - // Copied from MediaBrowser.Api/BaseApiService.cs - // TODO: Remove code duplication - /// <summary> - /// Gets the path segment at the specified index. - /// </summary> - /// <param name="index">The index of the path segment.</param> - /// <returns>The path segment at the specified index.</returns> - /// <exception cref="IndexOutOfRangeException" >Path doesn't contain enough segments.</exception> - /// <exception cref="InvalidDataException" >Path doesn't start with the base url.</exception> - protected internal ReadOnlySpan<char> GetPathValue(int index) - { - static void ThrowIndexOutOfRangeException() - => throw new IndexOutOfRangeException("Path doesn't contain enough segments."); - - static void ThrowInvalidDataException() - => throw new InvalidDataException("Path doesn't start with the base url."); - - ReadOnlySpan<char> path = Request.PathInfo; - - // Remove the protocol part from the url - int pos = path.LastIndexOf("://"); - if (pos != -1) - { - path = path.Slice(pos + 3); - } - - // Remove the query string - pos = path.LastIndexOf('?'); - if (pos != -1) - { - path = path.Slice(0, pos); - } - - // Remove the domain - pos = path.IndexOf('/'); - if (pos != -1) - { - path = path.Slice(pos); - } - - // Remove base url - string baseUrl = _configurationManager.Configuration.BaseUrl; - int baseUrlLen = baseUrl.Length; - if (baseUrlLen != 0) - { - if (path.StartsWith(baseUrl, StringComparison.OrdinalIgnoreCase)) - { - path = path.Slice(baseUrlLen); - } - else - { - // The path doesn't start with the base url, - // how did we get here? - ThrowInvalidDataException(); - } - } - - // Remove leading / - path = path.Slice(1); - - // Backwards compatibility - const string Emby = "emby/"; - if (path.StartsWith(Emby, StringComparison.OrdinalIgnoreCase)) - { - path = path.Slice(Emby.Length); - } - - const string MediaBrowser = "mediabrowser/"; - if (path.StartsWith(MediaBrowser, StringComparison.OrdinalIgnoreCase)) - { - path = path.Slice(MediaBrowser.Length); - } - - // Skip segments until we are at the right index - for (int i = 0; i < index; i++) - { - pos = path.IndexOf('/'); - if (pos == -1) - { - ThrowIndexOutOfRangeException(); - } - - path = path.Slice(pos + 1); - } - - // Remove the rest - pos = path.IndexOf('/'); - if (pos != -1) - { - path = path.Slice(0, pos); - } - - return path; - } - - public object Get(GetIcon request) - { - var contentType = "image/" + Path.GetExtension(request.Filename) - .TrimStart('.') - .ToLowerInvariant(); - - var cacheLength = TimeSpan.FromDays(365); - var cacheKey = Request.RawUrl.GetMD5(); - - return _resultFactory.GetStaticResult(Request, cacheKey, null, cacheLength, contentType, () => Task.FromResult(_dlnaManager.GetIcon(request.Filename).Stream)); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Subscribe(ProcessContentDirectoryEventRequest request) - { - return ProcessEventRequest(ContentDirectory); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Subscribe(ProcessConnectionManagerEventRequest request) - { - return ProcessEventRequest(ConnectionManager); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Subscribe(ProcessMediaReceiverRegistrarEventRequest request) - { - return ProcessEventRequest(MediaReceiverRegistrar); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Unsubscribe(ProcessContentDirectoryEventRequest request) - { - return ProcessEventRequest(ContentDirectory); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Unsubscribe(ProcessConnectionManagerEventRequest request) - { - return ProcessEventRequest(ConnectionManager); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Unsubscribe(ProcessMediaReceiverRegistrarEventRequest request) - { - return ProcessEventRequest(MediaReceiverRegistrar); - } - - private object ProcessEventRequest(IEventManager eventManager) - { - var subscriptionId = GetHeader("SID"); - - if (string.Equals(Request.Verb, "SUBSCRIBE", StringComparison.OrdinalIgnoreCase)) - { - var notificationType = GetHeader("NT"); - - var callback = GetHeader("CALLBACK"); - var timeoutString = GetHeader("TIMEOUT"); - - if (string.IsNullOrEmpty(notificationType)) - { - return GetSubscriptionResponse(eventManager.RenewEventSubscription(subscriptionId, notificationType, timeoutString, callback)); - } - - return GetSubscriptionResponse(eventManager.CreateEventSubscription(notificationType, timeoutString, callback)); - } - - return GetSubscriptionResponse(eventManager.CancelEventSubscription(subscriptionId)); - } - - private object GetSubscriptionResponse(EventSubscriptionResponse response) - { - return _resultFactory.GetResult(Request, response.Content, response.ContentType, response.Headers); - } - } -} diff --git a/Emby.Dlna/Api/DlnaService.cs b/Emby.Dlna/Api/DlnaService.cs deleted file mode 100644 index 5f984bb33..000000000 --- a/Emby.Dlna/Api/DlnaService.cs +++ /dev/null @@ -1,88 +0,0 @@ -#pragma warning disable CS1591 - -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Services; - -namespace Emby.Dlna.Api -{ - [Route("/Dlna/ProfileInfos", "GET", Summary = "Gets a list of profiles")] - public class GetProfileInfos : IReturn<DeviceProfileInfo[]> - { - } - - [Route("/Dlna/Profiles/{Id}", "DELETE", Summary = "Deletes a profile")] - public class DeleteProfile : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Profile Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/Dlna/Profiles/Default", "GET", Summary = "Gets the default profile")] - public class GetDefaultProfile : IReturn<DeviceProfile> - { - } - - [Route("/Dlna/Profiles/{Id}", "GET", Summary = "Gets a single profile")] - public class GetProfile : IReturn<DeviceProfile> - { - [ApiMember(Name = "Id", Description = "Profile Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Dlna/Profiles/{Id}", "POST", Summary = "Updates a profile")] - public class UpdateProfile : DeviceProfile, IReturnVoid - { - } - - [Route("/Dlna/Profiles", "POST", Summary = "Creates a profile")] - public class CreateProfile : DeviceProfile, IReturnVoid - { - } - - [Authenticated(Roles = "Admin")] - public class DlnaService : IService - { - private readonly IDlnaManager _dlnaManager; - - public DlnaService(IDlnaManager dlnaManager) - { - _dlnaManager = dlnaManager; - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetProfileInfos request) - { - return _dlnaManager.GetProfileInfos().ToArray(); - } - - public object Get(GetProfile request) - { - return _dlnaManager.GetProfile(request.Id); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetDefaultProfile request) - { - return _dlnaManager.GetDefaultProfile(); - } - - public void Delete(DeleteProfile request) - { - _dlnaManager.DeleteProfile(request.Id); - } - - public void Post(UpdateProfile request) - { - _dlnaManager.UpdateProfile(request); - } - - public void Post(CreateProfile request) - { - _dlnaManager.CreateProfile(request); - } - } -} diff --git a/Emby.Dlna/ContentDirectory/ControlHandler.cs b/Emby.Dlna/ContentDirectory/ControlHandler.cs index 291de5245..00821bf78 100644 --- a/Emby.Dlna/ContentDirectory/ControlHandler.cs +++ b/Emby.Dlna/ContentDirectory/ControlHandler.cs @@ -11,6 +11,7 @@ using System.Xml; using Emby.Dlna.Didl; using Emby.Dlna.Service; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs index 1e20ff92b..269f7ee43 100644 --- a/Emby.Dlna/DlnaManager.cs +++ b/Emby.Dlna/DlnaManager.cs @@ -122,15 +122,15 @@ namespace Emby.Dlna var builder = new StringBuilder(); builder.AppendLine("No matching device profile found. The default will need to be used."); - builder.AppendLine(string.Format("DeviceDescription:{0}", profile.DeviceDescription ?? string.Empty)); - builder.AppendLine(string.Format("FriendlyName:{0}", profile.FriendlyName ?? string.Empty)); - builder.AppendLine(string.Format("Manufacturer:{0}", profile.Manufacturer ?? string.Empty)); - builder.AppendLine(string.Format("ManufacturerUrl:{0}", profile.ManufacturerUrl ?? string.Empty)); - builder.AppendLine(string.Format("ModelDescription:{0}", profile.ModelDescription ?? string.Empty)); - builder.AppendLine(string.Format("ModelName:{0}", profile.ModelName ?? string.Empty)); - builder.AppendLine(string.Format("ModelNumber:{0}", profile.ModelNumber ?? string.Empty)); - builder.AppendLine(string.Format("ModelUrl:{0}", profile.ModelUrl ?? string.Empty)); - builder.AppendLine(string.Format("SerialNumber:{0}", profile.SerialNumber ?? string.Empty)); + builder.AppendFormat(CultureInfo.InvariantCulture, "DeviceDescription:{0}", profile.DeviceDescription ?? string.Empty).AppendLine(); + builder.AppendFormat(CultureInfo.InvariantCulture, "FriendlyName:{0}", profile.FriendlyName ?? string.Empty).AppendLine(); + builder.AppendFormat(CultureInfo.InvariantCulture, "Manufacturer:{0}", profile.Manufacturer ?? string.Empty).AppendLine(); + builder.AppendFormat(CultureInfo.InvariantCulture, "ManufacturerUrl:{0}", profile.ManufacturerUrl ?? string.Empty).AppendLine(); + builder.AppendFormat(CultureInfo.InvariantCulture, "ModelDescription:{0}", profile.ModelDescription ?? string.Empty).AppendLine(); + builder.AppendFormat(CultureInfo.InvariantCulture, "ModelName:{0}", profile.ModelName ?? string.Empty).AppendLine(); + builder.AppendFormat(CultureInfo.InvariantCulture, "ModelNumber:{0}", profile.ModelNumber ?? string.Empty).AppendLine(); + builder.AppendFormat(CultureInfo.InvariantCulture, "ModelUrl:{0}", profile.ModelUrl ?? string.Empty).AppendLine(); + builder.AppendFormat(CultureInfo.InvariantCulture, "SerialNumber:{0}", profile.SerialNumber ?? string.Empty).AppendLine(); _logger.LogInformation(builder.ToString()); } diff --git a/Emby.Dlna/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs index 9b9b57e97..a21d4cc11 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -54,11 +54,11 @@ namespace Emby.Dlna.Main private SsdpDevicePublisher _publisher; private ISsdpCommunicationsServer _communicationsServer; - internal IContentDirectory ContentDirectory { get; private set; } + public IContentDirectory ContentDirectory { get; private set; } - internal IConnectionManager ConnectionManager { get; private set; } + public IConnectionManager ConnectionManager { get; private set; } - internal IMediaReceiverRegistrar MediaReceiverRegistrar { get; private set; } + public IMediaReceiverRegistrar MediaReceiverRegistrar { get; private set; } public static DlnaEntryPoint Current; diff --git a/Emby.Notifications/Api/NotificationsService.cs b/Emby.Notifications/Api/NotificationsService.cs deleted file mode 100644 index 1ff8a5026..000000000 --- a/Emby.Notifications/Api/NotificationsService.cs +++ /dev/null @@ -1,191 +0,0 @@ -#pragma warning disable CS1591 -#pragma warning disable SA1402 -#pragma warning disable SA1649 - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Notifications; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Notifications; -using MediaBrowser.Model.Services; - -namespace Emby.Notifications.Api -{ - [Route("/Notifications/{UserId}", "GET", Summary = "Gets notifications")] - public class GetNotifications : IReturn<NotificationResult> - { - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string UserId { get; set; } = string.Empty; - - [ApiMember(Name = "IsRead", Description = "An optional filter by IsRead", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsRead { get; set; } - - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - } - - public class Notification - { - public string Id { get; set; } = string.Empty; - - public string UserId { get; set; } = string.Empty; - - public DateTime Date { get; set; } - - public bool IsRead { get; set; } - - public string Name { get; set; } = string.Empty; - - public string Description { get; set; } = string.Empty; - - public string Url { get; set; } = string.Empty; - - public NotificationLevel Level { get; set; } - } - - public class NotificationResult - { - public IReadOnlyList<Notification> Notifications { get; set; } = Array.Empty<Notification>(); - - public int TotalRecordCount { get; set; } - } - - public class NotificationsSummary - { - public int UnreadCount { get; set; } - - public NotificationLevel MaxUnreadNotificationLevel { get; set; } - } - - [Route("/Notifications/{UserId}/Summary", "GET", Summary = "Gets a notification summary for a user")] - public class GetNotificationsSummary : IReturn<NotificationsSummary> - { - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string UserId { get; set; } = string.Empty; - } - - [Route("/Notifications/Types", "GET", Summary = "Gets notification types")] - public class GetNotificationTypes : IReturn<List<NotificationTypeInfo>> - { - } - - [Route("/Notifications/Services", "GET", Summary = "Gets notification types")] - public class GetNotificationServices : IReturn<List<NameIdPair>> - { - } - - [Route("/Notifications/Admin", "POST", Summary = "Sends a notification to all admin users")] - public class AddAdminNotification : IReturnVoid - { - [ApiMember(Name = "Name", Description = "The notification's name", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Name { get; set; } = string.Empty; - - [ApiMember(Name = "Description", Description = "The notification's description", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Description { get; set; } = string.Empty; - - [ApiMember(Name = "ImageUrl", Description = "The notification's image url", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string? ImageUrl { get; set; } - - [ApiMember(Name = "Url", Description = "The notification's info url", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string? Url { get; set; } - - [ApiMember(Name = "Level", Description = "The notification level", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public NotificationLevel Level { get; set; } - } - - [Route("/Notifications/{UserId}/Read", "POST", Summary = "Marks notifications as read")] - public class MarkRead : IReturnVoid - { - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string UserId { get; set; } = string.Empty; - - [ApiMember(Name = "Ids", Description = "A list of notification ids, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)] - public string Ids { get; set; } = string.Empty; - } - - [Route("/Notifications/{UserId}/Unread", "POST", Summary = "Marks notifications as unread")] - public class MarkUnread : IReturnVoid - { - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string UserId { get; set; } = string.Empty; - - [ApiMember(Name = "Ids", Description = "A list of notification ids, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)] - public string Ids { get; set; } = string.Empty; - } - - [Authenticated] - public class NotificationsService : IService - { - private readonly INotificationManager _notificationManager; - private readonly IUserManager _userManager; - - public NotificationsService(INotificationManager notificationManager, IUserManager userManager) - { - _notificationManager = notificationManager; - _userManager = userManager; - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetNotificationTypes request) - { - return _notificationManager.GetNotificationTypes(); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetNotificationServices request) - { - return _notificationManager.GetNotificationServices().ToList(); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetNotificationsSummary request) - { - return new NotificationsSummary(); - } - - public Task Post(AddAdminNotification request) - { - // This endpoint really just exists as post of a real with sickbeard - var notification = new NotificationRequest - { - Date = DateTime.UtcNow, - Description = request.Description, - Level = request.Level, - Name = request.Name, - Url = request.Url, - UserIds = _userManager.Users - .Where(user => user.HasPermission(PermissionKind.IsAdministrator)) - .Select(user => user.Id) - .ToArray() - }; - - return _notificationManager.SendNotification(notification, CancellationToken.None); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public void Post(MarkRead request) - { - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public void Post(MarkUnread request) - { - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetNotifications request) - { - return new NotificationResult(); - } - } -} diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 12419fcbf..66556316b 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; @@ -46,7 +45,7 @@ using Emby.Server.Implementations.Session; using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; -using MediaBrowser.Api; +using Jellyfin.Api.Helpers; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; @@ -98,7 +97,6 @@ using MediaBrowser.Providers.Chapters; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.TheTvdb; using MediaBrowser.Providers.Subtitles; -using MediaBrowser.WebDashboard.Api; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -556,8 +554,6 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>(); serviceCollection.AddSingleton<IUserDataManager, UserDataManager>(); - serviceCollection.AddSingleton<IDisplayPreferencesRepository, SqliteDisplayPreferencesRepository>(); - serviceCollection.AddSingleton<IItemRepository, SqliteItemRepository>(); serviceCollection.AddSingleton<IAuthenticationRepository, AuthenticationRepository>(); @@ -637,6 +633,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<EncodingHelper>(); serviceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>(); + + serviceCollection.AddSingleton<TranscodingJobHelper>(); } /// <summary> @@ -653,7 +651,6 @@ namespace Emby.Server.Implementations _httpServer = Resolve<IHttpServer>(); _httpClient = Resolve<IHttpClient>(); - ((SqliteDisplayPreferencesRepository)Resolve<IDisplayPreferencesRepository>()).Initialize(); ((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize(); SetStaticProperties(); @@ -1037,12 +1034,6 @@ namespace Emby.Server.Implementations } } - // Include composable parts in the Api assembly - yield return typeof(ApiEntryPoint).Assembly; - - // Include composable parts in the Dashboard assembly - yield return typeof(DashboardService).Assembly; - // Include composable parts in the Model assembly yield return typeof(SystemInfo).Assembly; diff --git a/Emby.Server.Implementations/Browser/BrowserLauncher.cs b/Emby.Server.Implementations/Browser/BrowserLauncher.cs index 7a0294e07..f8108d1c2 100644 --- a/Emby.Server.Implementations/Browser/BrowserLauncher.cs +++ b/Emby.Server.Implementations/Browser/BrowserLauncher.cs @@ -1,5 +1,7 @@ using System; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Browser @@ -24,7 +26,7 @@ namespace Emby.Server.Implementations.Browser /// <param name="appHost">The app host.</param> public static void OpenSwaggerPage(IServerApplicationHost appHost) { - TryOpenUrl(appHost, "/swagger/index.html"); + TryOpenUrl(appHost, "/api-docs/swagger"); } /// <summary> diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index c803d9d82..d8ab1f1a1 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -7,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; @@ -22,6 +22,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Serialization; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Movie = MediaBrowser.Controller.Entities.Movies.Movie; @@ -45,10 +46,7 @@ namespace Emby.Server.Implementations.Channels private readonly IFileSystem _fileSystem; private readonly IJsonSerializer _jsonSerializer; private readonly IProviderManager _providerManager; - - private readonly ConcurrentDictionary<string, Tuple<DateTime, List<MediaSourceInfo>>> _channelItemMediaInfo = - new ConcurrentDictionary<string, Tuple<DateTime, List<MediaSourceInfo>>>(); - + private readonly IMemoryCache _memoryCache; private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1); /// <summary> @@ -63,6 +61,7 @@ namespace Emby.Server.Implementations.Channels /// <param name="userDataManager">The user data manager.</param> /// <param name="jsonSerializer">The JSON serializer.</param> /// <param name="providerManager">The provider manager.</param> + /// <param name="memoryCache">The memory cache.</param> public ChannelManager( IUserManager userManager, IDtoService dtoService, @@ -72,7 +71,8 @@ namespace Emby.Server.Implementations.Channels IFileSystem fileSystem, IUserDataManager userDataManager, IJsonSerializer jsonSerializer, - IProviderManager providerManager) + IProviderManager providerManager, + IMemoryCache memoryCache) { _userManager = userManager; _dtoService = dtoService; @@ -83,6 +83,7 @@ namespace Emby.Server.Implementations.Channels _userDataManager = userDataManager; _jsonSerializer = jsonSerializer; _providerManager = providerManager; + _memoryCache = memoryCache; } internal IChannel[] Channels { get; private set; } @@ -417,20 +418,15 @@ namespace Emby.Server.Implementations.Channels private async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken) { - if (_channelItemMediaInfo.TryGetValue(id, out Tuple<DateTime, List<MediaSourceInfo>> cachedInfo)) + if (_memoryCache.TryGetValue(id, out List<MediaSourceInfo> cachedInfo)) { - if ((DateTime.UtcNow - cachedInfo.Item1).TotalMinutes < 5) - { - return cachedInfo.Item2; - } + return cachedInfo; } var mediaInfo = await channel.GetChannelItemMediaInfo(id, cancellationToken) .ConfigureAwait(false); var list = mediaInfo.ToList(); - - var item2 = new Tuple<DateTime, List<MediaSourceInfo>>(DateTime.UtcNow, list); - _channelItemMediaInfo.AddOrUpdate(id, item2, (key, oldValue) => item2); + _memoryCache.Set(id, list, DateTimeOffset.UtcNow.AddMinutes(5)); return list; } diff --git a/Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs b/Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs deleted file mode 100644 index 5597155a8..000000000 --- a/Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs +++ /dev/null @@ -1,225 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Text.Json; -using System.Threading; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Json; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; - -namespace Emby.Server.Implementations.Data -{ - /// <summary> - /// Class SQLiteDisplayPreferencesRepository. - /// </summary> - public class SqliteDisplayPreferencesRepository : BaseSqliteRepository, IDisplayPreferencesRepository - { - private readonly IFileSystem _fileSystem; - - private readonly JsonSerializerOptions _jsonOptions; - - public SqliteDisplayPreferencesRepository(ILogger<SqliteDisplayPreferencesRepository> logger, IApplicationPaths appPaths, IFileSystem fileSystem) - : base(logger) - { - _fileSystem = fileSystem; - - _jsonOptions = JsonDefaults.GetOptions(); - - DbFilePath = Path.Combine(appPaths.DataPath, "displaypreferences.db"); - } - - /// <summary> - /// Gets the name of the repository. - /// </summary> - /// <value>The name.</value> - public string Name => "SQLite"; - - public void Initialize() - { - try - { - InitializeInternal(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error loading database file. Will reset and retry."); - - _fileSystem.DeleteFile(DbFilePath); - - InitializeInternal(); - } - } - - /// <summary> - /// Opens the connection to the database. - /// </summary> - /// <returns>Task.</returns> - private void InitializeInternal() - { - string[] queries = - { - "create table if not exists userdisplaypreferences (id GUID NOT NULL, userId GUID NOT NULL, client text NOT NULL, data BLOB NOT NULL)", - "create unique index if not exists userdisplaypreferencesindex on userdisplaypreferences (id, userId, client)" - }; - - using (var connection = GetConnection()) - { - connection.RunQueries(queries); - } - } - - /// <summary> - /// Save the display preferences associated with an item in the repo. - /// </summary> - /// <param name="displayPreferences">The display preferences.</param> - /// <param name="userId">The user id.</param> - /// <param name="client">The client.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <exception cref="ArgumentNullException">item</exception> - public void SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, CancellationToken cancellationToken) - { - if (displayPreferences == null) - { - throw new ArgumentNullException(nameof(displayPreferences)); - } - - if (string.IsNullOrEmpty(displayPreferences.Id)) - { - throw new ArgumentException("Display preferences has an invalid Id", nameof(displayPreferences)); - } - - cancellationToken.ThrowIfCancellationRequested(); - - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => SaveDisplayPreferences(displayPreferences, userId, client, db), - TransactionMode); - } - } - - private void SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, IDatabaseConnection connection) - { - var serialized = JsonSerializer.SerializeToUtf8Bytes(displayPreferences, _jsonOptions); - - using (var statement = connection.PrepareStatement("replace into userdisplaypreferences (id, userid, client, data) values (@id, @userId, @client, @data)")) - { - statement.TryBind("@id", new Guid(displayPreferences.Id).ToByteArray()); - statement.TryBind("@userId", userId.ToByteArray()); - statement.TryBind("@client", client); - statement.TryBind("@data", serialized); - - statement.MoveNext(); - } - } - - /// <summary> - /// Save all display preferences associated with a user in the repo. - /// </summary> - /// <param name="displayPreferences">The display preferences.</param> - /// <param name="userId">The user id.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <exception cref="ArgumentNullException">item</exception> - public void SaveAllDisplayPreferences(IEnumerable<DisplayPreferences> displayPreferences, Guid userId, CancellationToken cancellationToken) - { - if (displayPreferences == null) - { - throw new ArgumentNullException(nameof(displayPreferences)); - } - - cancellationToken.ThrowIfCancellationRequested(); - - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - foreach (var displayPreference in displayPreferences) - { - SaveDisplayPreferences(displayPreference, userId, displayPreference.Client, db); - } - }, - TransactionMode); - } - } - - /// <summary> - /// Gets the display preferences. - /// </summary> - /// <param name="displayPreferencesId">The display preferences id.</param> - /// <param name="userId">The user id.</param> - /// <param name="client">The client.</param> - /// <returns>Task{DisplayPreferences}.</returns> - /// <exception cref="ArgumentNullException">item</exception> - public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, Guid userId, string client) - { - if (string.IsNullOrEmpty(displayPreferencesId)) - { - throw new ArgumentNullException(nameof(displayPreferencesId)); - } - - var guidId = displayPreferencesId.GetMD5(); - - using (var connection = GetConnection(true)) - { - using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where id = @id and userId=@userId and client=@client")) - { - statement.TryBind("@id", guidId.ToByteArray()); - statement.TryBind("@userId", userId.ToByteArray()); - statement.TryBind("@client", client); - - foreach (var row in statement.ExecuteQuery()) - { - return Get(row); - } - } - } - - return new DisplayPreferences - { - Id = guidId.ToString("N", CultureInfo.InvariantCulture) - }; - } - - /// <summary> - /// Gets all display preferences for the given user. - /// </summary> - /// <param name="userId">The user id.</param> - /// <returns>Task{DisplayPreferences}.</returns> - /// <exception cref="ArgumentNullException">item</exception> - public IEnumerable<DisplayPreferences> GetAllDisplayPreferences(Guid userId) - { - var list = new List<DisplayPreferences>(); - - using (var connection = GetConnection(true)) - using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where userId=@userId")) - { - statement.TryBind("@userId", userId.ToByteArray()); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(Get(row)); - } - } - - return list; - } - - private DisplayPreferences Get(IReadOnlyList<IResultSetValue> row) - => JsonSerializer.Deserialize<DisplayPreferences>(row[0].ToBlob(), _jsonOptions); - - public void SaveDisplayPreferences(DisplayPreferences displayPreferences, string userId, string client, CancellationToken cancellationToken) - => SaveDisplayPreferences(displayPreferences, new Guid(userId), client, cancellationToken); - - public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, string userId, string client) - => GetDisplayPreferences(displayPreferencesId, new Guid(userId), client); - } -} diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 9f5566424..d11e5e62e 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -9,6 +9,7 @@ using System.Text; using System.Text.Json; using System.Threading; using Emby.Server.Implementations.Playlists; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Json; using MediaBrowser.Controller; @@ -400,6 +401,8 @@ namespace Emby.Server.Implementations.Data "OwnerId" }; + private static readonly string _retriveItemColumnsSelectQuery = $"select {string.Join(',', _retriveItemColumns)} from TypedBaseItems where guid = @guid"; + private static readonly string[] _mediaStreamSaveColumns = { "ItemId", @@ -439,6 +442,12 @@ namespace Emby.Server.Implementations.Data "ColorTransfer" }; + private static readonly string _mediaStreamSaveColumnsInsertQuery = + $"insert into mediastreams ({string.Join(',', _mediaStreamSaveColumns)}) values "; + + private static readonly string _mediaStreamSaveColumnsSelectQuery = + $"select {string.Join(',', _mediaStreamSaveColumns)} from mediastreams where ItemId=@ItemId"; + private static readonly string[] _mediaAttachmentSaveColumns = { "ItemId", @@ -450,102 +459,15 @@ namespace Emby.Server.Implementations.Data "MIMEType" }; - private static readonly string _mediaAttachmentInsertPrefix; - - private static string GetSaveItemCommandText() - { - var saveColumns = new[] - { - "guid", - "type", - "data", - "Path", - "StartDate", - "EndDate", - "ChannelId", - "IsMovie", - "IsSeries", - "EpisodeTitle", - "IsRepeat", - "CommunityRating", - "CustomRating", - "IndexNumber", - "IsLocked", - "Name", - "OfficialRating", - "MediaType", - "Overview", - "ParentIndexNumber", - "PremiereDate", - "ProductionYear", - "ParentId", - "Genres", - "InheritedParentalRatingValue", - "SortName", - "ForcedSortName", - "RunTimeTicks", - "Size", - "DateCreated", - "DateModified", - "PreferredMetadataLanguage", - "PreferredMetadataCountryCode", - "Width", - "Height", - "DateLastRefreshed", - "DateLastSaved", - "IsInMixedFolder", - "LockedFields", - "Studios", - "Audio", - "ExternalServiceId", - "Tags", - "IsFolder", - "UnratedType", - "TopParentId", - "TrailerTypes", - "CriticRating", - "CleanName", - "PresentationUniqueKey", - "OriginalTitle", - "PrimaryVersionId", - "DateLastMediaAdded", - "Album", - "IsVirtualItem", - "SeriesName", - "UserDataKey", - "SeasonName", - "SeasonId", - "SeriesId", - "ExternalSeriesId", - "Tagline", - "ProviderIds", - "Images", - "ProductionLocations", - "ExtraIds", - "TotalBitrate", - "ExtraType", - "Artists", - "AlbumArtists", - "ExternalId", - "SeriesPresentationUniqueKey", - "ShowId", - "OwnerId" - }; - - var saveItemCommandCommandText = "replace into TypedBaseItems (" + string.Join(",", saveColumns) + ") values ("; + private static readonly string _mediaAttachmentSaveColumnsSelectQuery = + $"select {string.Join(',', _mediaAttachmentSaveColumns)} from mediaattachments where ItemId=@ItemId"; - for (var i = 0; i < saveColumns.Length; i++) - { - if (i != 0) - { - saveItemCommandCommandText += ","; - } - - saveItemCommandCommandText += "@" + saveColumns[i]; - } + private static readonly string _mediaAttachmentInsertPrefix; - return saveItemCommandCommandText + ")"; - } + private const string SaveItemCommandText = + @"replace into TypedBaseItems + (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) + values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; /// <summary> /// Save a standard item in the repo. @@ -636,7 +558,7 @@ namespace Emby.Server.Implementations.Data { var statements = PrepareAll(db, new string[] { - GetSaveItemCommandText(), + SaveItemCommandText, "delete from AncestorIds where ItemId=@ItemId" }).ToList(); @@ -1056,7 +978,10 @@ namespace Emby.Server.Implementations.Data continue; } - str.Append($"{i.Key}={i.Value}|"); + str.Append(i.Key) + .Append('=') + .Append(i.Value) + .Append('|'); } if (str.Length == 0) @@ -1110,8 +1035,8 @@ namespace Emby.Server.Implementations.Data continue; } - str.Append(ToValueString(i)) - .Append('|'); + AppendItemImageInfo(str, i); + str.Append('|'); } str.Length -= 1; // Remove last | @@ -1145,26 +1070,26 @@ namespace Emby.Server.Implementations.Data item.ImageInfos = list.ToArray(); } - public string ToValueString(ItemImageInfo image) + public void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image) { - const string Delimeter = "*"; + const char Delimiter = '*'; var path = image.Path ?? string.Empty; var hash = image.BlurHash ?? string.Empty; - return GetPathToSave(path) + - Delimeter + - image.DateModified.Ticks.ToString(CultureInfo.InvariantCulture) + - Delimeter + - image.Type + - Delimeter + - image.Width.ToString(CultureInfo.InvariantCulture) + - Delimeter + - image.Height.ToString(CultureInfo.InvariantCulture) + - Delimeter + - // Replace delimiters with other characters. - // This can be removed when we migrate to a proper DB. - hash.Replace('*', '/').Replace('|', '\\'); + bldr.Append(GetPathToSave(path)) + .Append(Delimiter) + .Append(image.DateModified.Ticks) + .Append(Delimiter) + .Append(image.Type) + .Append(Delimiter) + .Append(image.Width) + .Append(Delimiter) + .Append(image.Height) + .Append(Delimiter) + // Replace delimiters with other characters. + // This can be removed when we migrate to a proper DB. + .Append(hash.Replace('*', '/').Replace('|', '\\')); } public ItemImageInfo ItemImageInfoFromValueString(string value) @@ -1226,7 +1151,7 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection(true)) { - using (var statement = PrepareStatement(connection, "select " + string.Join(",", _retriveItemColumns) + " from TypedBaseItems where guid = @guid")) + using (var statement = PrepareStatement(connection, _retriveItemColumnsSelectQuery)) { statement.TryBind("@guid", id); @@ -2776,82 +2701,82 @@ namespace Emby.Server.Implementations.Data private string FixUnicodeChars(string buffer) { - if (buffer.IndexOf('\u2013') > -1) + if (buffer.IndexOf('\u2013', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u2013', '-'); // en dash } - if (buffer.IndexOf('\u2014') > -1) + if (buffer.IndexOf('\u2014', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u2014', '-'); // em dash } - if (buffer.IndexOf('\u2015') > -1) + if (buffer.IndexOf('\u2015', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u2015', '-'); // horizontal bar } - if (buffer.IndexOf('\u2017') > -1) + if (buffer.IndexOf('\u2017', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u2017', '_'); // double low line } - if (buffer.IndexOf('\u2018') > -1) + if (buffer.IndexOf('\u2018', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u2018', '\''); // left single quotation mark } - if (buffer.IndexOf('\u2019') > -1) + if (buffer.IndexOf('\u2019', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u2019', '\''); // right single quotation mark } - if (buffer.IndexOf('\u201a') > -1) + if (buffer.IndexOf('\u201a', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u201a', ','); // single low-9 quotation mark } - if (buffer.IndexOf('\u201b') > -1) + if (buffer.IndexOf('\u201b', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u201b', '\''); // single high-reversed-9 quotation mark } - if (buffer.IndexOf('\u201c') > -1) + if (buffer.IndexOf('\u201c', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u201c', '\"'); // left double quotation mark } - if (buffer.IndexOf('\u201d') > -1) + if (buffer.IndexOf('\u201d', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u201d', '\"'); // right double quotation mark } - if (buffer.IndexOf('\u201e') > -1) + if (buffer.IndexOf('\u201e', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u201e', '\"'); // double low-9 quotation mark } - if (buffer.IndexOf('\u2026') > -1) + if (buffer.IndexOf('\u2026', StringComparison.Ordinal) > -1) { - buffer = buffer.Replace("\u2026", "..."); // horizontal ellipsis + buffer = buffer.Replace("\u2026", "...", StringComparison.Ordinal); // horizontal ellipsis } - if (buffer.IndexOf('\u2032') > -1) + if (buffer.IndexOf('\u2032', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u2032', '\''); // prime } - if (buffer.IndexOf('\u2033') > -1) + if (buffer.IndexOf('\u2033', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u2033', '\"'); // double prime } - if (buffer.IndexOf('\u0060') > -1) + if (buffer.IndexOf('\u0060', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u0060', '\''); // grave accent } - if (buffer.IndexOf('\u00B4') > -1) + if (buffer.IndexOf('\u00B4', StringComparison.Ordinal) > -1) { buffer = buffer.Replace('\u00B4', '\''); // acute accent } @@ -3000,7 +2925,6 @@ namespace Emby.Server.Implementations.Data { connection.RunInTransaction(db => { - var statements = PrepareAll(db, statementTexts).ToList(); if (!isReturningZeroItems) @@ -4670,8 +4594,12 @@ namespace Emby.Server.Implementations.Data if (query.BlockUnratedItems.Length > 1) { - var inClause = string.Join(",", query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'")); - whereClauses.Add(string.Format("(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))", inClause)); + var inClause = string.Join(',', query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'")); + whereClauses.Add( + string.Format( + CultureInfo.InvariantCulture, + "(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))", + inClause)); } if (query.ExcludeInheritedTags.Length > 0) @@ -4680,7 +4608,7 @@ namespace Emby.Server.Implementations.Data if (statement == null) { int index = 0; - string excludedTags = string.Join(",", query.ExcludeInheritedTags.Select(t => paramName + index++)); + string excludedTags = string.Join(',', query.ExcludeInheritedTags.Select(t => paramName + index++)); whereClauses.Add("((select CleanValue from itemvalues where ItemId=Guid and Type=6 and cleanvalue in (" + excludedTags + ")) is null)"); } else @@ -5734,10 +5662,10 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type const int Limit = 100; var startIndex = 0; + const string StartInsertText = "insert into ItemValues (ItemId, Type, Value, CleanValue) values "; + var insertText = new StringBuilder(StartInsertText); while (startIndex < values.Count) { - var insertText = new StringBuilder("insert into ItemValues (ItemId, Type, Value, CleanValue) values "); - var endIndex = Math.Min(values.Count, startIndex + Limit); for (var i = startIndex; i < endIndex; i++) @@ -5779,6 +5707,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } startIndex += Limit; + insertText.Length = StartInsertText.Length; } } @@ -5816,10 +5745,10 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type var startIndex = 0; var listIndex = 0; + const string StartInsertText = "insert into People (ItemId, Name, Role, PersonType, SortOrder, ListOrder) values "; + var insertText = new StringBuilder(StartInsertText); while (startIndex < people.Count) { - var insertText = new StringBuilder("insert into People (ItemId, Name, Role, PersonType, SortOrder, ListOrder) values "); - var endIndex = Math.Min(people.Count, startIndex + Limit); for (var i = startIndex; i < endIndex; i++) { @@ -5853,6 +5782,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } startIndex += Limit; + insertText.Length = StartInsertText.Length; } } @@ -5891,10 +5821,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type throw new ArgumentNullException(nameof(query)); } - var cmdText = "select " - + string.Join(",", _mediaStreamSaveColumns) - + " from mediastreams where" - + " ItemId=@ItemId"; + var cmdText = _mediaStreamSaveColumnsSelectQuery; if (query.Type.HasValue) { @@ -5971,18 +5898,9 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type const int Limit = 10; var startIndex = 0; + var insertText = new StringBuilder(_mediaStreamSaveColumnsInsertQuery); while (startIndex < streams.Count) { - var insertText = new StringBuilder("insert into mediastreams ("); - foreach (var column in _mediaStreamSaveColumns) - { - insertText.Append(column).Append(','); - } - - // Remove last comma - insertText.Length--; - insertText.Append(") values "); - var endIndex = Math.Min(streams.Count, startIndex + Limit); for (var i = startIndex; i < endIndex; i++) @@ -6065,6 +5983,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } startIndex += Limit; + insertText.Length = _mediaStreamSaveColumnsInsertQuery.Length; } } @@ -6248,10 +6167,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type throw new ArgumentNullException(nameof(query)); } - var cmdText = "select " - + string.Join(",", _mediaAttachmentSaveColumns) - + " from mediaattachments where" - + " ItemId=@ItemId"; + var cmdText = _mediaAttachmentSaveColumnsSelectQuery; if (query.Index.HasValue) { @@ -6319,10 +6235,9 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type { const int InsertAtOnce = 10; + var insertText = new StringBuilder(_mediaAttachmentInsertPrefix); for (var startIndex = 0; startIndex < attachments.Count; startIndex += InsertAtOnce) { - var insertText = new StringBuilder(_mediaAttachmentInsertPrefix); - var endIndex = Math.Min(attachments.Count, startIndex + InsertAtOnce); for (var i = startIndex; i < endIndex; i++) @@ -6368,6 +6283,8 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type statement.Reset(); statement.MoveNext(); } + + insertText.Length = _mediaAttachmentInsertPrefix.Length; } } diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs index e75745cc6..cc4b407f5 100644 --- a/Emby.Server.Implementations/Devices/DeviceManager.cs +++ b/Emby.Server.Implementations/Devices/DeviceManager.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using Jellyfin.Data.Enums; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; @@ -17,16 +17,17 @@ using MediaBrowser.Model.Events; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Session; +using Microsoft.Extensions.Caching.Memory; namespace Emby.Server.Implementations.Devices { public class DeviceManager : IDeviceManager { + private readonly IMemoryCache _memoryCache; private readonly IJsonSerializer _json; private readonly IUserManager _userManager; private readonly IServerConfigurationManager _config; private readonly IAuthenticationRepository _authRepo; - private readonly Dictionary<string, ClientCapabilities> _capabilitiesCache; private readonly object _capabilitiesSyncLock = new object(); public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; @@ -35,13 +36,14 @@ namespace Emby.Server.Implementations.Devices IAuthenticationRepository authRepo, IJsonSerializer json, IUserManager userManager, - IServerConfigurationManager config) + IServerConfigurationManager config, + IMemoryCache memoryCache) { _json = json; _userManager = userManager; _config = config; + _memoryCache = memoryCache; _authRepo = authRepo; - _capabilitiesCache = new Dictionary<string, ClientCapabilities>(StringComparer.OrdinalIgnoreCase); } public void SaveCapabilities(string deviceId, ClientCapabilities capabilities) @@ -51,8 +53,7 @@ namespace Emby.Server.Implementations.Devices lock (_capabilitiesSyncLock) { - _capabilitiesCache[deviceId] = capabilities; - + _memoryCache.Set(deviceId, capabilities); _json.SerializeToFile(capabilities, path); } } @@ -71,13 +72,13 @@ namespace Emby.Server.Implementations.Devices public ClientCapabilities GetCapabilities(string id) { - lock (_capabilitiesSyncLock) + if (_memoryCache.TryGetValue(id, out ClientCapabilities result)) { - if (_capabilitiesCache.TryGetValue(id, out var result)) - { - return result; - } + return result; + } + lock (_capabilitiesSyncLock) + { var path = Path.Combine(GetDevicePath(id), "capabilities.json"); try { diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 4351b9aa5..1adef68aa 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -13,10 +13,8 @@ <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> <ProjectReference Include="..\MediaBrowser.Providers\MediaBrowser.Providers.csproj" /> - <ProjectReference Include="..\MediaBrowser.WebDashboard\MediaBrowser.WebDashboard.csproj" /> <ProjectReference Include="..\MediaBrowser.XbmcMetadata\MediaBrowser.XbmcMetadata.csproj" /> <ProjectReference Include="..\Emby.Dlna\Emby.Dlna.csproj" /> - <ProjectReference Include="..\MediaBrowser.Api\MediaBrowser.Api.csproj" /> <ProjectReference Include="..\MediaBrowser.LocalMetadata\MediaBrowser.LocalMetadata.csproj" /> <ProjectReference Include="..\Emby.Photos\Emby.Photos.csproj" /> <ProjectReference Include="..\Emby.Drawing\Emby.Drawing.csproj" /> @@ -25,7 +23,7 @@ <ItemGroup> <PackageReference Include="IPNetwork2" Version="2.5.211" /> - <PackageReference Include="Jellyfin.XmlTv" Version="10.6.0-pre1" /> + <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" /> <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" /> <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Hosting.Server.Abstractions" Version="2.2.0" /> @@ -38,10 +36,10 @@ <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.6" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.6" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="3.1.6" /> - <PackageReference Include="Mono.Nat" Version="2.0.1" /> + <PackageReference Include="Mono.Nat" Version="2.0.2" /> <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.3.1" /> - <PackageReference Include="ServiceStack.Text.Core" Version="5.9.0" /> - <PackageReference Include="sharpcompress" Version="0.25.1" /> + <PackageReference Include="ServiceStack.Text.Core" Version="5.9.2" /> + <PackageReference Include="sharpcompress" Version="0.26.0" /> <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" /> <PackageReference Include="DotNet.Glob" Version="3.0.9" /> </ItemGroup> @@ -54,7 +52,7 @@ <TargetFramework>netstandard2.1</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'" >true</TreatWarningsAsErrors> + <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors> </PropertyGroup> <!-- Code Analyzers--> diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index c1068522a..1deef7f72 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -23,10 +23,12 @@ namespace Emby.Server.Implementations.EntryPoints public class LibraryChangedNotifier : IServerEntryPoint { /// <summary> - /// The library manager. + /// The library update duration. /// </summary> - private readonly ILibraryManager _libraryManager; + private const int LibraryUpdateDuration = 30000; + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; private readonly ILogger<LibraryChangedNotifier> _logger; @@ -38,23 +40,10 @@ namespace Emby.Server.Implementations.EntryPoints private readonly List<Folder> _foldersAddedTo = new List<Folder>(); private readonly List<Folder> _foldersRemovedFrom = new List<Folder>(); - private readonly List<BaseItem> _itemsAdded = new List<BaseItem>(); private readonly List<BaseItem> _itemsRemoved = new List<BaseItem>(); private readonly List<BaseItem> _itemsUpdated = new List<BaseItem>(); - - /// <summary> - /// Gets or sets the library update timer. - /// </summary> - /// <value>The library update timer.</value> - private Timer LibraryUpdateTimer { get; set; } - - /// <summary> - /// The library update duration. - /// </summary> - private const int LibraryUpdateDuration = 30000; - - private readonly IProviderManager _providerManager; + private readonly Dictionary<Guid, DateTime> _lastProgressMessageTimes = new Dictionary<Guid, DateTime>(); public LibraryChangedNotifier( ILibraryManager libraryManager, @@ -70,22 +59,26 @@ namespace Emby.Server.Implementations.EntryPoints _providerManager = providerManager; } + /// <summary> + /// Gets or sets the library update timer. + /// </summary> + /// <value>The library update timer.</value> + private Timer LibraryUpdateTimer { get; set; } + public Task RunAsync() { - _libraryManager.ItemAdded += libraryManager_ItemAdded; - _libraryManager.ItemUpdated += libraryManager_ItemUpdated; - _libraryManager.ItemRemoved += libraryManager_ItemRemoved; + _libraryManager.ItemAdded += OnLibraryItemAdded; + _libraryManager.ItemUpdated += OnLibraryItemUpdated; + _libraryManager.ItemRemoved += OnLibraryItemRemoved; - _providerManager.RefreshCompleted += _providerManager_RefreshCompleted; - _providerManager.RefreshStarted += _providerManager_RefreshStarted; - _providerManager.RefreshProgress += _providerManager_RefreshProgress; + _providerManager.RefreshCompleted += OnProviderRefreshCompleted; + _providerManager.RefreshStarted += OnProviderRefreshStarted; + _providerManager.RefreshProgress += OnProviderRefreshProgress; return Task.CompletedTask; } - private Dictionary<Guid, DateTime> _lastProgressMessageTimes = new Dictionary<Guid, DateTime>(); - - private void _providerManager_RefreshProgress(object sender, GenericEventArgs<Tuple<BaseItem, double>> e) + private void OnProviderRefreshProgress(object sender, GenericEventArgs<Tuple<BaseItem, double>> e) { var item = e.Argument.Item1; @@ -122,9 +115,11 @@ namespace Emby.Server.Implementations.EntryPoints foreach (var collectionFolder in collectionFolders) { - var collectionFolderDict = new Dictionary<string, string>(); - collectionFolderDict["ItemId"] = collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture); - collectionFolderDict["Progress"] = (collectionFolder.GetRefreshProgress() ?? 0).ToString(CultureInfo.InvariantCulture); + var collectionFolderDict = new Dictionary<string, string> + { + ["ItemId"] = collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture), + ["Progress"] = (collectionFolder.GetRefreshProgress() ?? 0).ToString(CultureInfo.InvariantCulture) + }; try { @@ -136,21 +131,19 @@ namespace Emby.Server.Implementations.EntryPoints } } - private void _providerManager_RefreshStarted(object sender, GenericEventArgs<BaseItem> e) + private void OnProviderRefreshStarted(object sender, GenericEventArgs<BaseItem> e) { - _providerManager_RefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0))); + OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0))); } - private void _providerManager_RefreshCompleted(object sender, GenericEventArgs<BaseItem> e) + private void OnProviderRefreshCompleted(object sender, GenericEventArgs<BaseItem> e) { - _providerManager_RefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100))); + OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100))); } private static bool EnableRefreshMessage(BaseItem item) { - var folder = item as Folder; - - if (folder == null) + if (!(item is Folder folder)) { return false; } @@ -183,7 +176,7 @@ namespace Emby.Server.Implementations.EntryPoints /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - void libraryManager_ItemAdded(object sender, ItemChangeEventArgs e) + private void OnLibraryItemAdded(object sender, ItemChangeEventArgs e) { if (!FilterItem(e.Item)) { @@ -205,8 +198,7 @@ namespace Emby.Server.Implementations.EntryPoints LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); } - var parent = e.Item.GetParent() as Folder; - if (parent != null) + if (e.Item.GetParent() is Folder parent) { _foldersAddedTo.Add(parent); } @@ -220,7 +212,7 @@ namespace Emby.Server.Implementations.EntryPoints /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - void libraryManager_ItemUpdated(object sender, ItemChangeEventArgs e) + private void OnLibraryItemUpdated(object sender, ItemChangeEventArgs e) { if (!FilterItem(e.Item)) { @@ -231,8 +223,7 @@ namespace Emby.Server.Implementations.EntryPoints { if (LibraryUpdateTimer == null) { - LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, - Timeout.Infinite); + LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite); } else { @@ -248,7 +239,7 @@ namespace Emby.Server.Implementations.EntryPoints /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - void libraryManager_ItemRemoved(object sender, ItemChangeEventArgs e) + private void OnLibraryItemRemoved(object sender, ItemChangeEventArgs e) { if (!FilterItem(e.Item)) { @@ -259,16 +250,14 @@ namespace Emby.Server.Implementations.EntryPoints { if (LibraryUpdateTimer == null) { - LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, - Timeout.Infinite); + LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite); } else { LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); } - var parent = e.Parent as Folder; - if (parent != null) + if (e.Parent is Folder parent) { _foldersRemovedFrom.Add(parent); } @@ -486,13 +475,13 @@ namespace Emby.Server.Implementations.EntryPoints LibraryUpdateTimer = null; } - _libraryManager.ItemAdded -= libraryManager_ItemAdded; - _libraryManager.ItemUpdated -= libraryManager_ItemUpdated; - _libraryManager.ItemRemoved -= libraryManager_ItemRemoved; + _libraryManager.ItemAdded -= OnLibraryItemAdded; + _libraryManager.ItemUpdated -= OnLibraryItemUpdated; + _libraryManager.ItemRemoved -= OnLibraryItemRemoved; - _providerManager.RefreshCompleted -= _providerManager_RefreshCompleted; - _providerManager.RefreshStarted -= _providerManager_RefreshStarted; - _providerManager.RefreshProgress -= _providerManager_RefreshProgress; + _providerManager.RefreshCompleted -= OnProviderRefreshCompleted; + _providerManager.RefreshStarted -= OnProviderRefreshStarted; + _providerManager.RefreshProgress -= OnProviderRefreshProgress; } } } diff --git a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs index c3428ee62..dafdd5b7b 100644 --- a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs +++ b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs @@ -449,7 +449,7 @@ namespace Emby.Server.Implementations.HttpServer if (string.Equals(httpReq.Verb, "OPTIONS", StringComparison.OrdinalIgnoreCase)) { httpRes.StatusCode = 200; - foreach(var (key, value) in GetDefaultCorsHeaders(httpReq)) + foreach (var (key, value) in GetDefaultCorsHeaders(httpReq)) { httpRes.Headers.Add(key, value); } @@ -486,7 +486,7 @@ namespace Emby.Server.Implementations.HttpServer var handler = GetServiceHandler(httpReq); if (handler != null) { - await handler.ProcessRequestAsync(this, httpReq, httpRes, _logger, cancellationToken).ConfigureAwait(false); + await handler.ProcessRequestAsync(this, httpReq, httpRes, cancellationToken).ConfigureAwait(false); } else { @@ -567,7 +567,7 @@ namespace Emby.Server.Implementations.HttpServer WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); - var connection = new WebSocketConnection( + using var connection = new WebSocketConnection( _loggerFactory.CreateLogger<WebSocketConnection>(), webSocket, context.Connection.RemoteIpAddress, diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index c9f802a51..76c1d9bac 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -35,9 +35,9 @@ namespace Emby.Server.Implementations.HttpServer.Security _networkManager = networkManager; } - public void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues) + public void Authenticate(IRequest request, IAuthenticationAttributes authAttributes) { - ValidateUser(request, authAttribtues); + ValidateUser(request, authAttributes); } public User Authenticate(HttpRequest request, IAuthenticationAttributes authAttributes) @@ -63,17 +63,17 @@ namespace Emby.Server.Implementations.HttpServer.Security return auth; } - private User ValidateUser(IRequest request, IAuthenticationAttributes authAttribtues) + private User ValidateUser(IRequest request, IAuthenticationAttributes authAttributes) { // This code is executed before the service var auth = _authorizationContext.GetAuthorizationInfo(request); - if (!IsExemptFromAuthenticationToken(authAttribtues, request)) + if (!IsExemptFromAuthenticationToken(authAttributes, request)) { ValidateSecurityToken(request, auth.Token); } - if (authAttribtues.AllowLocalOnly && !request.IsLocal) + if (authAttributes.AllowLocalOnly && !request.IsLocal) { throw new SecurityException("Operation not found."); } @@ -87,14 +87,14 @@ namespace Emby.Server.Implementations.HttpServer.Security if (user != null) { - ValidateUserAccess(user, request, authAttribtues, auth); + ValidateUserAccess(user, request, authAttributes); } var info = GetTokenInfo(request); - if (!IsExemptFromRoles(auth, authAttribtues, request, info)) + if (!IsExemptFromRoles(auth, authAttributes, request, info)) { - var roles = authAttribtues.GetRoles(); + var roles = authAttributes.GetRoles(); ValidateRoles(roles, user); } @@ -118,8 +118,7 @@ namespace Emby.Server.Implementations.HttpServer.Security private void ValidateUserAccess( User user, IRequest request, - IAuthenticationAttributes authAttributes, - AuthorizationInfo auth) + IAuthenticationAttributes authAttributes) { if (user.HasPermission(PermissionKind.IsDisabled)) { @@ -158,6 +157,11 @@ namespace Emby.Server.Implementations.HttpServer.Security return true; } + if (authAttribtues.IgnoreLegacyAuth) + { + return true; + } + return false; } @@ -237,16 +241,6 @@ namespace Emby.Server.Implementations.HttpServer.Security { throw new AuthenticationException("Access token is invalid or expired."); } - - // if (!string.IsNullOrEmpty(info.UserId)) - //{ - // var user = _userManager.GetUserById(info.UserId); - - // if (user == null || user.Configuration.IsDisabled) - // { - // 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 078ce0d8a..fb93fae3e 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs @@ -99,6 +99,12 @@ namespace Emby.Server.Implementations.HttpServer.Security if (string.IsNullOrEmpty(token)) { + token = queryString["ApiKey"]; + } + + // TODO deprecate this query parameter. + if (string.IsNullOrEmpty(token)) + { token = queryString["api_key"]; } @@ -276,12 +282,7 @@ namespace Emby.Server.Implementations.HttpServer.Security private static string NormalizeValue(string value) { - if (string.IsNullOrEmpty(value)) - { - return value; - } - - return WebUtility.HtmlEncode(value); + return string.IsNullOrEmpty(value) ? value : WebUtility.HtmlEncode(value); } } } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index 316cd84cf..d738047e0 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -19,7 +19,7 @@ namespace Emby.Server.Implementations.HttpServer /// <summary> /// Class WebSocketConnection. /// </summary> - public class WebSocketConnection : IWebSocketConnection + public class WebSocketConnection : IWebSocketConnection, IDisposable { /// <summary> /// The logger. @@ -119,7 +119,7 @@ namespace Emby.Server.Implementations.HttpServer Memory<byte> memory = writer.GetMemory(512); try { - receiveresult = await _socket.ReceiveAsync(memory, cancellationToken); + receiveresult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); } catch (WebSocketException ex) { @@ -137,7 +137,7 @@ namespace Emby.Server.Implementations.HttpServer writer.Advance(bytesRead); // Make the data available to the PipeReader - FlushResult flushResult = await writer.FlushAsync(); + FlushResult flushResult = await writer.FlushAsync().ConfigureAwait(false); if (flushResult.IsCompleted) { // The PipeReader stopped reading @@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.HttpServer if (info.MessageType.Equals("KeepAlive", StringComparison.Ordinal)) { - await SendKeepAliveResponse(); + await SendKeepAliveResponse().ConfigureAwait(false); } else { diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index ef93779aa..fe74f1de7 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -21,6 +21,7 @@ namespace Emby.Server.Implementations.IO private readonly List<string> _affectedPaths = new List<string>(); private readonly object _timerLock = new object(); private Timer _timer; + private bool _disposed; public FileRefresher(string path, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger logger) { @@ -213,11 +214,11 @@ namespace Emby.Server.Implementations.IO } } - private bool _disposed; public void Dispose() { _disposed = true; DisposeTimer(); + GC.SuppressFinalize(this); } } } diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index da88b8d8a..161b4c452 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using System.IO; -using Emby.Server.Implementations.Images; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; diff --git a/Emby.Server.Implementations/Images/FolderImageProvider.cs b/Emby.Server.Implementations/Images/FolderImageProvider.cs index e9523386e..0224ab32a 100644 --- a/Emby.Server.Implementations/Images/FolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/FolderImageProvider.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 using System.Collections.Generic; -using Emby.Server.Implementations.Images; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs index d2aeccdb2..1cd4cd66b 100644 --- a/Emby.Server.Implementations/Images/GenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System.Collections.Generic; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index 6e6ef1359..e30a67593 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -18,7 +18,21 @@ namespace Emby.Server.Implementations.Library { "**/small.jpg", "**/albumart.jpg", - "**/*sample*", + + // We have neither non-greedy matching or character group repetitions, working around that here. + // https://github.com/dazinator/DotNet.Glob#patterns + // .*/sample\..{1,5} + "**/sample.?", + "**/sample.??", + "**/sample.???", // Matches sample.mkv + "**/sample.????", // Matches sample.webm + "**/sample.?????", + "**/*.sample.?", + "**/*.sample.??", + "**/*.sample.???", + "**/*.sample.????", + "**/*.sample.?????", + "**/sample/*", // Directories "**/metadata/**", @@ -64,10 +78,13 @@ namespace Emby.Server.Implementations.Library "**/.grab/**", "**/.grab", - // Unix hidden files and directories - "**/.*/**", + // Unix hidden files "**/.*", + // Mac - if you ever remove the above. + // "**/._*", + // "**/.DS_Store", + // thumbs.db "**/thumbs.db", diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 9165ede35..7b770d940 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1,7 +1,6 @@ #pragma warning disable CS1591 using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -46,11 +45,11 @@ using MediaBrowser.Model.Net; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.MediaInfo; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Genre = MediaBrowser.Controller.Entities.Genre; using Person = MediaBrowser.Controller.Entities.Person; -using SortOrder = MediaBrowser.Model.Entities.SortOrder; using VideoResolver = Emby.Naming.Video.VideoResolver; namespace Emby.Server.Implementations.Library @@ -63,6 +62,7 @@ namespace Emby.Server.Implementations.Library private const string ShortcutFileExtension = ".mblink"; private readonly ILogger<LibraryManager> _logger; + private readonly IMemoryCache _memoryCache; private readonly ITaskManager _taskManager; private readonly IUserManager _userManager; private readonly IUserDataManager _userDataRepository; @@ -74,7 +74,6 @@ namespace Emby.Server.Implementations.Library private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; private readonly IItemRepository _itemRepository; - private readonly ConcurrentDictionary<Guid, BaseItem> _libraryItemsCache; private readonly IImageProcessor _imageProcessor; /// <summary> @@ -112,6 +111,7 @@ namespace Emby.Server.Implementations.Library /// <param name="mediaEncoder">The media encoder.</param> /// <param name="itemRepository">The item repository.</param> /// <param name="imageProcessor">The image processor.</param> + /// <param name="memoryCache">The memory cache.</param> public LibraryManager( IServerApplicationHost appHost, ILogger<LibraryManager> logger, @@ -125,7 +125,8 @@ namespace Emby.Server.Implementations.Library Lazy<IUserViewManager> userviewManagerFactory, IMediaEncoder mediaEncoder, IItemRepository itemRepository, - IImageProcessor imageProcessor) + IImageProcessor imageProcessor, + IMemoryCache memoryCache) { _appHost = appHost; _logger = logger; @@ -140,8 +141,7 @@ namespace Emby.Server.Implementations.Library _mediaEncoder = mediaEncoder; _itemRepository = itemRepository; _imageProcessor = imageProcessor; - - _libraryItemsCache = new ConcurrentDictionary<Guid, BaseItem>(); + _memoryCache = memoryCache; _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -299,7 +299,7 @@ namespace Emby.Server.Implementations.Library } } - _libraryItemsCache.AddOrUpdate(item.Id, item, delegate { return item; }); + _memoryCache.Set(item.Id, item); } public void DeleteItem(BaseItem item, DeleteOptions options) @@ -447,7 +447,7 @@ namespace Emby.Server.Implementations.Library _itemRepository.DeleteItem(child.Id); } - _libraryItemsCache.TryRemove(item.Id, out BaseItem removed); + _memoryCache.Remove(item.Id); ReportItemRemoved(item, parent); } @@ -1248,7 +1248,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("Guid can't be empty", nameof(id)); } - if (_libraryItemsCache.TryGetValue(id, out BaseItem item)) + if (_memoryCache.TryGetValue(id, out BaseItem item)) { return item; } @@ -1591,7 +1591,6 @@ namespace Emby.Server.Implementations.Library public async Task<IEnumerable<Video>> GetIntros(BaseItem item, User user) { var tasks = IntroProviders - .OrderBy(i => i.GetType().Name.Contains("Default", StringComparison.OrdinalIgnoreCase) ? 1 : 0) .Take(1) .Select(i => GetIntros(i, item, user)); diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index bd59ee0e4..67cf8bf5b 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -46,8 +46,6 @@ namespace Emby.Server.Implementations.Library private readonly Dictionary<string, ILiveStream> _openStreams = new Dictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1); - private readonly object _disposeLock = new object(); - private IMediaSourceProvider[] _providers; public MediaSourceManager( @@ -623,12 +621,14 @@ namespace Emby.Server.Implementations.Library if (liveStreamInfo is IDirectStreamProvider) { - var info = await _mediaEncoder.GetMediaInfo(new MediaInfoRequest - { - MediaSource = mediaSource, - ExtractChapters = false, - MediaType = DlnaProfileType.Video - }, cancellationToken).ConfigureAwait(false); + var info = await _mediaEncoder.GetMediaInfo( + new MediaInfoRequest + { + MediaSource = mediaSource, + ExtractChapters = false, + MediaType = DlnaProfileType.Video + }, + cancellationToken).ConfigureAwait(false); mediaSource.MediaStreams = info.MediaStreams; mediaSource.Container = info.Container; @@ -859,11 +859,11 @@ namespace Emby.Server.Implementations.Library } } - private Tuple<IMediaSourceProvider, string> GetProvider(string key) + private (IMediaSourceProvider, string) GetProvider(string key) { if (string.IsNullOrEmpty(key)) { - throw new ArgumentException("key"); + throw new ArgumentException("Key can't be empty.", nameof(key)); } var keys = key.Split(new[] { LiveStreamIdDelimeter }, 2); @@ -873,7 +873,7 @@ namespace Emby.Server.Implementations.Library var splitIndex = key.IndexOf(LiveStreamIdDelimeter, StringComparison.Ordinal); var keyId = key.Substring(splitIndex + 1); - return new Tuple<IMediaSourceProvider, string>(provider, keyId); + return (provider, keyId); } /// <summary> @@ -893,15 +893,12 @@ namespace Emby.Server.Implementations.Library { if (dispose) { - lock (_disposeLock) + foreach (var key in _openStreams.Keys.ToList()) { - foreach (var key in _openStreams.Keys.ToList()) - { - var task = CloseLiveStream(key); - - Task.WaitAll(task); - } + CloseLiveStream(key).GetAwaiter().GetResult(); } + + _liveStreamSemaphore.Dispose(); } } } diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index 0bdc59914..877fdec86 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -4,12 +4,12 @@ using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs index d67c9e542..9a69bce0e 100644 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ b/Emby.Server.Implementations/Library/SearchEngine.cs @@ -4,12 +4,12 @@ using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Search; using Microsoft.Extensions.Logging; diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index 7b0fcbc9e..80e09f0a3 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -12,6 +12,7 @@ using System.Threading; using System.Threading.Tasks; using System.Xml; using Emby.Server.Implementations.Library; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index 57c5b7500..d4a88e299 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -77,7 +77,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - public class HdHomerunManager : IDisposable + public sealed class HdHomerunManager : IDisposable { public const int HdHomeRunPort = 65001; @@ -105,6 +105,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun StopStreaming(socket).GetAwaiter().GetResult(); } } + + GC.SuppressFinalize(this); } public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken) @@ -162,7 +164,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } _activeTuner = i; - var lockKeyString = string.Format("{0:d}", lockKeyValue); + var lockKeyString = string.Format(CultureInfo.InvariantCulture, "{0:d}", lockKeyValue); var lockkeyMsg = CreateSetMessage(i, "lockkey", lockKeyString, null); await stream.WriteAsync(lockkeyMsg, 0, lockkeyMsg.Length, cancellationToken).ConfigureAwait(false); int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); @@ -173,8 +175,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun continue; } - var commandList = commands.GetCommands(); - foreach (var command in commandList) + foreach (var command in commands.GetCommands()) { var channelMsg = CreateSetMessage(i, command.Item1, command.Item2, lockKeyValue); await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false); @@ -188,7 +189,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - var targetValue = string.Format("rtp://{0}:{1}", localIp, localPort); + var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIp, localPort); var targetMsg = CreateSetMessage(i, "target", targetValue, lockKeyValue); await stream.WriteAsync(targetMsg, 0, targetMsg.Length, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json index 1f309f3ff..ca14d4471 100644 --- a/Emby.Server.Implementations/Localization/Core/bn.json +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -7,7 +7,7 @@ "CameraImageUploadedFrom": "একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে {0} থেকে", "Books": "বই", "AuthenticationSucceededWithUserName": "{0} যাচাই সফল", - "Artists": "শিল্পী", + "Artists": "শিল্পীরা", "Application": "অ্যাপ্লিকেশন", "Albums": "অ্যালবামগুলো", "HeaderFavoriteEpisodes": "প্রিব পর্বগুলো", @@ -19,7 +19,7 @@ "Genres": "ঘরানা", "Folders": "ফোল্ডারগুলো", "Favorites": "ফেভারিটগুলো", - "FailedLoginAttemptWithUserName": "{0} থেকে লগিন করতে ব্যর্থ", + "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", "AppDeviceValues": "এপ: {0}, ডিভাইস: {0}", "VersionNumber": "সংস্করণ {0}", "ValueSpecialEpisodeName": "বিশেষ - {0}", diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index a6e9779c9..fcbe9566e 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -5,7 +5,7 @@ "Artists": "Interpreten", "AuthenticationSucceededWithUserName": "{0} hat sich erfolgreich angemeldet", "Books": "Bücher", - "CameraImageUploadedFrom": "Ein neues Foto wurde von {0} hochgeladen", + "CameraImageUploadedFrom": "Ein neues Kamerafoto wurde von {0} hochgeladen", "Channels": "Kanäle", "ChapterNameValue": "Kapitel {0}", "Collections": "Sammlungen", @@ -101,12 +101,12 @@ "TaskCleanTranscode": "Lösche Transkodier Pfad", "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche dazu eingestellt sind automatisch zu updaten und installiert sie.", "TaskUpdatePlugins": "Update Plugins", - "TaskRefreshPeopleDescription": "Erneuert Metadaten für Schausteller und Regisseure in deinen Bibliotheken.", - "TaskRefreshPeople": "Erneuere Schausteller", + "TaskRefreshPeopleDescription": "Erneuert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.", + "TaskRefreshPeople": "Erneuere Schauspieler", "TaskCleanLogsDescription": "Lösche Log Dateien die älter als {0} Tage sind.", "TaskCleanLogs": "Lösche Log Pfad", "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken für hinzugefügte Datein und erneuere Metadaten.", - "TaskRefreshLibrary": "Scanne alle Bibliotheken", + "TaskRefreshLibrary": "Scanne Medien-Bibliothek", "TaskRefreshChapterImagesDescription": "Kreiert Vorschaubilder für Videos welche Kapitel haben.", "TaskRefreshChapterImages": "Extrahiert Kapitel-Bilder", "TaskCleanCacheDescription": "Löscht Zwischenspeicherdatein die nicht länger von System gebraucht werden.", diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 682f5325b..dc3a98154 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -18,13 +18,13 @@ "HeaderAlbumArtists": "אמני האלבום", "HeaderCameraUploads": "העלאות ממצלמה", "HeaderContinueWatching": "המשך לצפות", - "HeaderFavoriteAlbums": "אלבומים שאהבתי", + "HeaderFavoriteAlbums": "אלבומים מועדפים", "HeaderFavoriteArtists": "אמנים מועדפים", "HeaderFavoriteEpisodes": "פרקים מועדפים", - "HeaderFavoriteShows": "סדרות מועדפות", + "HeaderFavoriteShows": "תוכניות מועדפות", "HeaderFavoriteSongs": "שירים מועדפים", "HeaderLiveTV": "שידורים חיים", - "HeaderNextUp": "הבא", + "HeaderNextUp": "הבא בתור", "HeaderRecordingGroups": "קבוצות הקלטה", "HomeVideos": "סרטונים בייתים", "Inherit": "הורש", @@ -45,37 +45,37 @@ "NameSeasonNumber": "עונה {0}", "NameSeasonUnknown": "עונה לא ידועה", "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", + "NotificationOptionApplicationUpdateAvailable": "קיים עדכון זמין ליישום", + "NotificationOptionApplicationUpdateInstalled": "עדכון ליישום הותקן", + "NotificationOptionAudioPlayback": "ניגון שמע החל", + "NotificationOptionAudioPlaybackStopped": "ניגון שמע הופסק", + "NotificationOptionCameraImageUploaded": "תמונת מצלמה הועלתה", "NotificationOptionInstallationFailed": "התקנה נכשלה", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", + "NotificationOptionNewLibraryContent": "תוכן חדש הוסף", + "NotificationOptionPluginError": "כשלון בתוסף", "NotificationOptionPluginInstalled": "התוסף הותקן", "NotificationOptionPluginUninstalled": "התוסף הוסר", "NotificationOptionPluginUpdateInstalled": "העדכון לתוסף הותקן", "NotificationOptionServerRestartRequired": "יש לאתחל את השרת", - "NotificationOptionTaskFailed": "Scheduled task failure", - "NotificationOptionUserLockedOut": "User locked out", - "NotificationOptionVideoPlayback": "Video playback started", - "NotificationOptionVideoPlaybackStopped": "Video playback stopped", + "NotificationOptionTaskFailed": "משימה מתוזמנת נכשלה", + "NotificationOptionUserLockedOut": "משתמש ננעל", + "NotificationOptionVideoPlayback": "ניגון וידאו החל", + "NotificationOptionVideoPlaybackStopped": "ניגון וידאו הופסק", "Photos": "תמונות", "Playlists": "רשימות הפעלה", "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", + "PluginInstalledWithName": "{0} הותקן", + "PluginUninstalledWithName": "{0} הוסר", + "PluginUpdatedWithName": "{0} עודכן", "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", + "ScheduledTaskFailedWithName": "{0} נכשל", + "ScheduledTaskStartedWithName": "{0} החל", + "ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש", "Shows": "סדרות", "Songs": "שירים", "StartupEmbyServerIsLoading": "שרת Jellyfin בהליכי טעינה. אנא נסה שנית בעוד זמן קצר.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", + "SubtitleDownloadFailureFromForItem": "הורדת כתוביות נכשלה מ-{0} עבור {1}", "Sync": "סנכרן", "System": "System", "TvShows": "סדרות טלוויזיה", @@ -83,14 +83,14 @@ "UserCreatedWithName": "המשתמש {0} נוצר", "UserDeletedWithName": "המשתמש {0} הוסר", "UserDownloadingItemWithValues": "{0} מוריד את {1}", - "UserLockedOutWithName": "User {0} has been locked out", - "UserOfflineFromDevice": "{0} has disconnected from {1}", - "UserOnlineFromDevice": "{0} is online from {1}", - "UserPasswordChangedWithName": "Password has been changed for user {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", + "UserLockedOutWithName": "המשתמש {0} ננעל", + "UserOfflineFromDevice": "{0} התנתק מ-{1}", + "UserOnlineFromDevice": "{0} מחובר מ-{1}", + "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}", + "UserPolicyUpdatedWithName": "מדיניות המשתמש {0} עודכנה", "UserStartedPlayingItemWithValues": "{0} מנגן את {1} על {2}", "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} על {2}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "ValueHasBeenAddedToLibrary": "{0} התווסף לספריית המדיה שלך", "ValueSpecialEpisodeName": "מיוחד- {0}", "VersionNumber": "Version {0}", "TaskRefreshLibrary": "סרוק ספריית מדיה", @@ -109,7 +109,7 @@ "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות לסרטונים שיש להם פרקים.", "TasksChannelsCategory": "ערוצי אינטרנט", "TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט עבור הכתוביות החסרות בהתבסס על המטה-דיאטה.", - "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות.", + "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות", "TaskRefreshChannelsDescription": "רענן פרטי ערוץ אינטרנטי.", "TaskRefreshChannels": "רענן ערוץ", "TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.", diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 7f5a56e86..0e27806dd 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -84,8 +84,8 @@ "UserDeletedWithName": "L'utente {0} è stato rimosso", "UserDownloadingItemWithValues": "{0} sta scaricando {1}", "UserLockedOutWithName": "L'utente {0} è stato bloccato", - "UserOfflineFromDevice": "{0} è stato disconnesso da {1}", - "UserOnlineFromDevice": "{0} è online da {1}", + "UserOfflineFromDevice": "{0} si è disconnesso su {1}", + "UserOnlineFromDevice": "{0} è online su {1}", "UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}", "UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}", "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1} su {2}", diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index b2e0b66fe..e673465a4 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -1,13 +1,13 @@ { - "MusicVideos": "Музичні відео", + "MusicVideos": "Музичні кліпи", "Music": "Музика", "Movies": "Фільми", - "MessageApplicationUpdatedTo": "Jellyfin Server був оновлений до версії {0}", - "MessageApplicationUpdated": "Jellyfin Server був оновлений", + "MessageApplicationUpdatedTo": "Jellyfin Server оновлено до версії {0}", + "MessageApplicationUpdated": "Jellyfin Server оновлено", "Latest": "Останні", - "LabelIpAddressValue": "IP-адреси: {0}", - "ItemRemovedWithName": "{0} видалено з бібліотеки", - "ItemAddedWithName": "{0} додано до бібліотеки", + "LabelIpAddressValue": "IP-адреса: {0}", + "ItemRemovedWithName": "{0} видалено з медіатеки", + "ItemAddedWithName": "{0} додано до медіатеки", "HeaderNextUp": "Наступний", "HeaderLiveTV": "Ефірне ТБ", "HeaderFavoriteSongs": "Улюблені пісні", @@ -17,20 +17,101 @@ "HeaderFavoriteAlbums": "Улюблені альбоми", "HeaderContinueWatching": "Продовжити перегляд", "HeaderCameraUploads": "Завантажено з камери", - "HeaderAlbumArtists": "Виконавці альбомів", + "HeaderAlbumArtists": "Виконавці альбому", "Genres": "Жанри", - "Folders": "Директорії", + "Folders": "Каталоги", "Favorites": "Улюблені", - "DeviceOnlineWithName": "{0} під'єднано", - "DeviceOfflineWithName": "{0} від'єднано", + "DeviceOnlineWithName": "Пристрій {0} підключився", + "DeviceOfflineWithName": "Пристрій {0} відключився", "Collections": "Колекції", - "ChapterNameValue": "Глава {0}", + "ChapterNameValue": "Розділ {0}", "Channels": "Канали", "CameraImageUploadedFrom": "Нова фотографія завантажена з {0}", "Books": "Книги", - "AuthenticationSucceededWithUserName": "{0} успішно авторизовані", + "AuthenticationSucceededWithUserName": "{0} успішно авторизований", "Artists": "Виконавці", "Application": "Додаток", "AppDeviceValues": "Додаток: {0}, Пристрій: {1}", - "Albums": "Альбоми" + "Albums": "Альбоми", + "NotificationOptionServerRestartRequired": "Необхідно перезапустити сервер", + "NotificationOptionPluginUpdateInstalled": "Встановлено оновлення плагіна", + "NotificationOptionPluginUninstalled": "Плагін видалено", + "NotificationOptionPluginInstalled": "Плагін встановлено", + "NotificationOptionPluginError": "Помилка плагіна", + "NotificationOptionNewLibraryContent": "Додано новий контент", + "HomeVideos": "Домашнє відео", + "FailedLoginAttemptWithUserName": "Невдала спроба входу від {0}", + "LabelRunningTimeValue": "Тривалість: {0}", + "TaskDownloadMissingSubtitlesDescription": "Шукає в Інтернеті відсутні субтитри на основі конфігурації метаданих.", + "TaskDownloadMissingSubtitles": "Завантажити відсутні субтитри", + "TaskRefreshChannelsDescription": "Оновлення інформації про Інтернет-канали.", + "TaskRefreshChannels": "Оновити канали", + "TaskCleanTranscodeDescription": "Вилучає файли для перекодування старше одного дня.", + "TaskCleanTranscode": "Очистити каталог перекодування", + "TaskUpdatePluginsDescription": "Завантажує та встановлює оновлення для плагінів, налаштованих на автоматичне оновлення.", + "TaskUpdatePlugins": "Оновити плагіни", + "TaskRefreshPeopleDescription": "Оновлення метаданих для акторів та режисерів у вашій медіатеці.", + "TaskRefreshPeople": "Оновити людей", + "TaskCleanLogsDescription": "Видаляє файли журналу, яким більше {0} днів.", + "TaskCleanLogs": "Очистити журнали", + "TaskRefreshLibraryDescription": "Сканує медіатеку на нові файли та оновлює метадані.", + "TaskRefreshLibrary": "Сканувати медіатеку", + "TaskRefreshChapterImagesDescription": "Створює ескізи для відео, які мають розділи.", + "TaskRefreshChapterImages": "Створити ескізи розділів", + "TaskCleanCacheDescription": "Видаляє файли кешу, які більше не потрібні системі.", + "TaskCleanCache": "Очистити кеш", + "TasksChannelsCategory": "Інтернет-канали", + "TasksApplicationCategory": "Додаток", + "TasksLibraryCategory": "Медіатека", + "TasksMaintenanceCategory": "Обслуговування", + "VersionNumber": "Версія {0}", + "ValueSpecialEpisodeName": "Спецепізод - {0}", + "ValueHasBeenAddedToLibrary": "{0} додано до медіатеки", + "UserStoppedPlayingItemWithValues": "{0} закінчив відтворення {1} на {2}", + "UserStartedPlayingItemWithValues": "{0} відтворює {1} на {2}", + "UserPolicyUpdatedWithName": "Політика користувача оновлена для {0}", + "UserPasswordChangedWithName": "Пароль змінено для користувача {0}", + "UserOnlineFromDevice": "{0} підключився з {1}", + "UserOfflineFromDevice": "{0} відключився від {1}", + "UserLockedOutWithName": "Користувача {0} заблоковано", + "UserDownloadingItemWithValues": "{0} завантажує {1}", + "UserDeletedWithName": "Користувача {0} видалено", + "UserCreatedWithName": "Користувача {0} створено", + "User": "Користувач", + "TvShows": "ТВ-шоу", + "System": "Система", + "Sync": "Синхронізація", + "SubtitleDownloadFailureFromForItem": "Не вдалося завантажити субтитри з {0} для {1}", + "StartupEmbyServerIsLoading": "Jellyfin Server завантажується. Будь ласка, спробуйте трішки пізніше.", + "Songs": "Пісні", + "Shows": "Шоу", + "ServerNameNeedsToBeRestarted": "{0} потрібно перезапустити", + "ScheduledTaskStartedWithName": "{0} розпочато", + "ScheduledTaskFailedWithName": "Помилка {0}", + "ProviderValue": "Постачальник: {0}", + "PluginUpdatedWithName": "{0} оновлено", + "PluginUninstalledWithName": "{0} видалено", + "PluginInstalledWithName": "{0} встановлено", + "Plugin": "Плагін", + "Playlists": "Плейлисти", + "Photos": "Фотографії", + "NotificationOptionVideoPlaybackStopped": "Відтворення відео зупинено", + "NotificationOptionVideoPlayback": "Розпочато відтворення відео", + "NotificationOptionUserLockedOut": "Користувача заблоковано", + "NotificationOptionTaskFailed": "Помилка запланованого завдання", + "NotificationOptionInstallationFailed": "Помилка встановлення", + "NotificationOptionCameraImageUploaded": "Фотографію завантажено", + "NotificationOptionAudioPlaybackStopped": "Відтворення аудіо зупинено", + "NotificationOptionAudioPlayback": "Розпочато відтворення аудіо", + "NotificationOptionApplicationUpdateInstalled": "Встановлено оновлення додатка", + "NotificationOptionApplicationUpdateAvailable": "Доступне оновлення додатка", + "NewVersionIsAvailable": "Для завантаження доступна нова версія Jellyfin Server.", + "NameSeasonUnknown": "Сезон Невідомий", + "NameSeasonNumber": "Сезон {0}", + "NameInstallFailed": "Не вдалося встановити {0}", + "MixedContent": "Змішаний контент", + "MessageServerConfigurationUpdated": "Конфігурація сервера оновлена", + "MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено", + "Inherit": "Успадкувати", + "HeaderRecordingGroups": "Групи запису" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index a22f66df9..a21cdad95 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -92,7 +92,7 @@ "HeaderRecordingGroups": "錄製組", "Inherit": "繼承", "SubtitleDownloadFailureFromForItem": "無法為 {1} 從 {0} 下載字幕", - "TaskDownloadMissingSubtitlesDescription": "在網路上透過描述資料搜尋遺失的字幕。", + "TaskDownloadMissingSubtitlesDescription": "在網路上透過中繼資料搜尋遺失的字幕。", "TaskDownloadMissingSubtitles": "下載遺失的字幕", "TaskRefreshChannels": "重新整理頻道", "TaskUpdatePlugins": "更新插件", diff --git a/Emby.Server.Implementations/Net/UdpSocket.cs b/Emby.Server.Implementations/Net/UdpSocket.cs index b51c03446..4e25768cf 100644 --- a/Emby.Server.Implementations/Net/UdpSocket.cs +++ b/Emby.Server.Implementations/Net/UdpSocket.cs @@ -15,13 +15,11 @@ namespace Emby.Server.Implementations.Net public sealed class UdpSocket : ISocket, IDisposable { private Socket _socket; - private int _localPort; + private readonly int _localPort; private bool _disposed = false; public Socket Socket => _socket; - public IPAddress LocalIPAddress { get; } - private readonly SocketAsyncEventArgs _receiveSocketAsyncEventArgs = new SocketAsyncEventArgs() { SocketFlags = SocketFlags.None @@ -51,18 +49,33 @@ namespace Emby.Server.Implementations.Net InitReceiveSocketAsyncEventArgs(); } + public UdpSocket(Socket socket, IPEndPoint endPoint) + { + if (socket == null) + { + throw new ArgumentNullException(nameof(socket)); + } + + _socket = socket; + _socket.Connect(endPoint); + + InitReceiveSocketAsyncEventArgs(); + } + + public IPAddress LocalIPAddress { get; } + private void InitReceiveSocketAsyncEventArgs() { var receiveBuffer = new byte[8192]; _receiveSocketAsyncEventArgs.SetBuffer(receiveBuffer, 0, receiveBuffer.Length); - _receiveSocketAsyncEventArgs.Completed += _receiveSocketAsyncEventArgs_Completed; + _receiveSocketAsyncEventArgs.Completed += OnReceiveSocketAsyncEventArgsCompleted; var sendBuffer = new byte[8192]; _sendSocketAsyncEventArgs.SetBuffer(sendBuffer, 0, sendBuffer.Length); - _sendSocketAsyncEventArgs.Completed += _sendSocketAsyncEventArgs_Completed; + _sendSocketAsyncEventArgs.Completed += OnSendSocketAsyncEventArgsCompleted; } - private void _receiveSocketAsyncEventArgs_Completed(object sender, SocketAsyncEventArgs e) + private void OnReceiveSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e) { var tcs = _currentReceiveTaskCompletionSource; if (tcs != null) @@ -86,7 +99,7 @@ namespace Emby.Server.Implementations.Net } } - private void _sendSocketAsyncEventArgs_Completed(object sender, SocketAsyncEventArgs e) + private void OnSendSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e) { var tcs = _currentSendTaskCompletionSource; if (tcs != null) @@ -104,19 +117,6 @@ namespace Emby.Server.Implementations.Net } } - public UdpSocket(Socket socket, IPEndPoint endPoint) - { - if (socket == null) - { - throw new ArgumentNullException(nameof(socket)); - } - - _socket = socket; - _socket.Connect(endPoint); - - InitReceiveSocketAsyncEventArgs(); - } - public IAsyncResult BeginReceive(byte[] buffer, int offset, int count, AsyncCallback callback) { ThrowIfDisposed(); @@ -247,6 +247,7 @@ namespace Emby.Server.Implementations.Net } } + /// <inheritdoc /> public void Dispose() { if (_disposed) @@ -255,6 +256,8 @@ namespace Emby.Server.Implementations.Net } _socket?.Dispose(); + _receiveSocketAsyncEventArgs.Dispose(); + _sendSocketAsyncEventArgs.Dispose(); _currentReceiveTaskCompletionSource?.TrySetCanceled(); _currentSendTaskCompletionSource?.TrySetCanceled(); diff --git a/Emby.Server.Implementations/Networking/NetworkManager.cs b/Emby.Server.Implementations/Networking/NetworkManager.cs index ff95302ee..089ec30e6 100644 --- a/Emby.Server.Implementations/Networking/NetworkManager.cs +++ b/Emby.Server.Implementations/Networking/NetworkManager.cs @@ -165,7 +165,7 @@ namespace Emby.Server.Implementations.Networking (octet[0] == 127) || // RFC1122 (octet[0] == 169 && octet[1] == 254)) // RFC3927 { - return false; + return true; } if (checkSubnets && IsInPrivateAddressSpaceAndLocalSubnet(endpoint)) diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 5dd1af4b8..38ceadedb 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -349,16 +349,14 @@ namespace Emby.Server.Implementations.Playlists AlbumTitle = child.Album }; - var hasAlbumArtist = child as IHasAlbumArtist; - if (hasAlbumArtist != null) + if (child is IHasAlbumArtist hasAlbumArtist) { - entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); + entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null; } - var hasArtist = child as IHasArtist; - if (hasArtist != null) + if (child is IHasArtist hasArtist) { - entry.TrackArtist = hasArtist.Artists.FirstOrDefault(); + entry.TrackArtist = hasArtist.Artists.Count > 0 ? hasArtist.Artists[0] : null; } if (child.RunTimeTicks.HasValue) @@ -385,16 +383,14 @@ namespace Emby.Server.Implementations.Playlists AlbumTitle = child.Album }; - var hasAlbumArtist = child as IHasAlbumArtist; - if (hasAlbumArtist != null) + if (child is IHasAlbumArtist hasAlbumArtist) { - entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); + entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null; } - var hasArtist = child as IHasArtist; - if (hasArtist != null) + if (child is IHasArtist hasArtist) { - entry.TrackArtist = hasArtist.Artists.FirstOrDefault(); + entry.TrackArtist = hasArtist.Artists.Count > 0 ? hasArtist.Artists[0] : null; } if (child.RunTimeTicks.HasValue) @@ -411,8 +407,10 @@ namespace Emby.Server.Implementations.Playlists if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase)) { - var playlist = new M3uPlaylist(); - playlist.IsExtended = true; + var playlist = new M3uPlaylist + { + IsExtended = true + }; foreach (var child in item.GetLinkedChildren()) { var entry = new M3uPlaylistEntry() @@ -422,10 +420,9 @@ namespace Emby.Server.Implementations.Playlists Album = child.Album }; - var hasAlbumArtist = child as IHasAlbumArtist; - if (hasAlbumArtist != null) + if (child is IHasAlbumArtist hasAlbumArtist) { - entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); + entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null; } if (child.RunTimeTicks.HasValue) @@ -453,10 +450,9 @@ namespace Emby.Server.Implementations.Playlists Album = child.Album }; - var hasAlbumArtist = child as IHasAlbumArtist; - if (hasAlbumArtist != null) + if (child is IHasAlbumArtist hasAlbumArtist) { - entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); + entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null; } if (child.RunTimeTicks.HasValue) @@ -514,7 +510,7 @@ namespace Emby.Server.Implementations.Playlists if (!folderPath.EndsWith(Path.DirectorySeparatorChar)) { - folderPath = folderPath + Path.DirectorySeparatorChar; + folderPath += Path.DirectorySeparatorChar; } var folderUri = new Uri(folderPath); @@ -537,32 +533,12 @@ namespace Emby.Server.Implementations.Playlists return relativePath; } - private static string UnEscape(string content) - { - if (content == null) - { - return content; - } - - return content.Replace("&", "&").Replace("'", "'").Replace(""", "\"").Replace(">", ">").Replace("<", "<"); - } - - private static string Escape(string content) - { - if (content == null) - { - return null; - } - - return content.Replace("&", "&").Replace("'", "'").Replace("\"", """).Replace(">", ">").Replace("<", "<"); - } - public Folder GetPlaylistsFolder(Guid userId) { - var typeName = "PlaylistsFolder"; + const string TypeName = "PlaylistsFolder"; - return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal)) ?? - _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal)); + return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)) ?? + _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)); } } } diff --git a/Emby.Server.Implementations/Services/ServiceController.cs b/Emby.Server.Implementations/Services/ServiceController.cs index d884d4f37..47e7261e8 100644 --- a/Emby.Server.Implementations/Services/ServiceController.cs +++ b/Emby.Server.Implementations/Services/ServiceController.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Threading.Tasks; using Emby.Server.Implementations.HttpServer; using MediaBrowser.Model.Services; @@ -91,12 +92,22 @@ namespace Emby.Server.Implementations.Services { if (restPath.Path[0] != '/') { - throw new ArgumentException(string.Format("Route '{0}' on '{1}' must start with a '/'", restPath.Path, restPath.RequestType.GetMethodName())); + throw new ArgumentException( + string.Format( + CultureInfo.InvariantCulture, + "Route '{0}' on '{1}' must start with a '/'", + restPath.Path, + restPath.RequestType.GetMethodName())); } if (restPath.Path.IndexOfAny(InvalidRouteChars) != -1) { - throw new ArgumentException(string.Format("Route '{0}' on '{1}' contains invalid chars. ", restPath.Path, restPath.RequestType.GetMethodName())); + throw new ArgumentException( + string.Format( + CultureInfo.InvariantCulture, + "Route '{0}' on '{1}' contains invalid chars. ", + restPath.Path, + restPath.RequestType.GetMethodName())); } if (RestPathMap.TryGetValue(restPath.FirstMatchHashKey, out List<RestPath> pathsAtFirstMatch)) @@ -179,8 +190,7 @@ namespace Emby.Server.Implementations.Services var service = httpHost.CreateInstance(serviceType); - var serviceRequiresContext = service as IRequiresRequest; - if (serviceRequiresContext != null) + if (service is IRequiresRequest serviceRequiresContext) { serviceRequiresContext.Request = req; } diff --git a/Emby.Server.Implementations/Services/ServiceHandler.cs b/Emby.Server.Implementations/Services/ServiceHandler.cs index c87e418c2..b4166f771 100644 --- a/Emby.Server.Implementations/Services/ServiceHandler.cs +++ b/Emby.Server.Implementations/Services/ServiceHandler.cs @@ -71,7 +71,7 @@ namespace Emby.Server.Implementations.Services return null; } - public async Task ProcessRequestAsync(HttpListenerHost httpHost, IRequest httpReq, HttpResponse httpRes, ILogger logger, CancellationToken cancellationToken) + public async Task ProcessRequestAsync(HttpListenerHost httpHost, IRequest httpReq, HttpResponse httpRes, CancellationToken cancellationToken) { httpReq.Items["__route"] = _restPath; @@ -80,10 +80,10 @@ namespace Emby.Server.Implementations.Services httpReq.ResponseContentType = _responseContentType; } - var request = await CreateRequest(httpHost, httpReq, _restPath, logger).ConfigureAwait(false); + var request = await CreateRequest(httpHost, httpReq, _restPath).ConfigureAwait(false); httpHost.ApplyRequestFilters(httpReq, httpRes, request); - + httpRes.HttpContext.SetServiceStackRequest(httpReq); var response = await httpHost.ServiceController.Execute(httpHost, request, httpReq).ConfigureAwait(false); @@ -96,7 +96,7 @@ namespace Emby.Server.Implementations.Services await ResponseHelper.WriteToResponse(httpRes, httpReq, response, cancellationToken).ConfigureAwait(false); } - public static async Task<object> CreateRequest(HttpListenerHost host, IRequest httpReq, RestPath restPath, ILogger logger) + public static async Task<object> CreateRequest(HttpListenerHost host, IRequest httpReq, RestPath restPath) { var requestType = restPath.RequestType; diff --git a/Emby.Server.Implementations/Services/SwaggerService.cs b/Emby.Server.Implementations/Services/SwaggerService.cs deleted file mode 100644 index 4f011a678..000000000 --- a/Emby.Server.Implementations/Services/SwaggerService.cs +++ /dev/null @@ -1,287 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using Emby.Server.Implementations.HttpServer; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; - -namespace Emby.Server.Implementations.Services -{ - [Route("/swagger", "GET", Summary = "Gets the swagger specifications")] - [Route("/swagger.json", "GET", Summary = "Gets the swagger specifications")] - public class GetSwaggerSpec : IReturn<SwaggerSpec> - { - } - - public class SwaggerSpec - { - public string swagger { get; set; } - - public string[] schemes { get; set; } - - public SwaggerInfo info { get; set; } - - public string host { get; set; } - - public string basePath { get; set; } - - public SwaggerTag[] tags { get; set; } - - public IDictionary<string, Dictionary<string, SwaggerMethod>> paths { get; set; } - - public Dictionary<string, SwaggerDefinition> definitions { get; set; } - - public SwaggerComponents components { get; set; } - } - - public class SwaggerComponents - { - public Dictionary<string, SwaggerSecurityScheme> securitySchemes { get; set; } - } - - public class SwaggerSecurityScheme - { - public string name { get; set; } - - public string type { get; set; } - - public string @in { get; set; } - } - - public class SwaggerInfo - { - public string description { get; set; } - - public string version { get; set; } - - public string title { get; set; } - - public string termsOfService { get; set; } - - public SwaggerConcactInfo contact { get; set; } - } - - public class SwaggerConcactInfo - { - public string email { get; set; } - - public string name { get; set; } - - public string url { get; set; } - } - - public class SwaggerTag - { - public string description { get; set; } - - public string name { get; set; } - } - - public class SwaggerMethod - { - public string summary { get; set; } - - public string description { get; set; } - - public string[] tags { get; set; } - - public string operationId { get; set; } - - public string[] consumes { get; set; } - - public string[] produces { get; set; } - - public SwaggerParam[] parameters { get; set; } - - public Dictionary<string, SwaggerResponse> responses { get; set; } - - public Dictionary<string, string[]>[] security { get; set; } - } - - public class SwaggerParam - { - public string @in { get; set; } - - public string name { get; set; } - - public string description { get; set; } - - public bool required { get; set; } - - public string type { get; set; } - - public string collectionFormat { get; set; } - } - - public class SwaggerResponse - { - public string description { get; set; } - - // ex. "$ref":"#/definitions/Pet" - public Dictionary<string, string> schema { get; set; } - } - - public class SwaggerDefinition - { - public string type { get; set; } - - public Dictionary<string, SwaggerProperty> properties { get; set; } - } - - public class SwaggerProperty - { - public string type { get; set; } - - public string format { get; set; } - - public string description { get; set; } - - public string[] @enum { get; set; } - - public string @default { get; set; } - } - - public class SwaggerService : IService, IRequiresRequest - { - private readonly IHttpServer _httpServer; - private SwaggerSpec _spec; - - public IRequest Request { get; set; } - - public SwaggerService(IHttpServer httpServer) - { - _httpServer = httpServer; - } - - public object Get(GetSwaggerSpec request) - { - return _spec ?? (_spec = GetSpec()); - } - - private SwaggerSpec GetSpec() - { - string host = null; - Uri uri; - if (Uri.TryCreate(Request.RawUrl, UriKind.Absolute, out uri)) - { - host = uri.Host; - } - - var securitySchemes = new Dictionary<string, SwaggerSecurityScheme>(); - - securitySchemes["api_key"] = new SwaggerSecurityScheme - { - name = "api_key", - type = "apiKey", - @in = "query" - }; - - var spec = new SwaggerSpec - { - schemes = new[] { "http" }, - tags = GetTags(), - swagger = "2.0", - info = new SwaggerInfo - { - title = "Jellyfin Server API", - version = "1.0.0", - description = "Explore the Jellyfin Server API", - contact = new SwaggerConcactInfo - { - name = "Jellyfin Community", - url = "https://jellyfin.readthedocs.io/en/latest/user-docs/getting-help/" - } - }, - paths = GetPaths(), - definitions = GetDefinitions(), - basePath = "/jellyfin", - host = host, - - components = new SwaggerComponents - { - securitySchemes = securitySchemes - } - }; - - return spec; - } - - - private SwaggerTag[] GetTags() - { - return Array.Empty<SwaggerTag>(); - } - - private Dictionary<string, SwaggerDefinition> GetDefinitions() - { - return new Dictionary<string, SwaggerDefinition>(); - } - - private IDictionary<string, Dictionary<string, SwaggerMethod>> GetPaths() - { - var paths = new SortedDictionary<string, Dictionary<string, SwaggerMethod>>(); - - // REVIEW: this can be done better - var all = ((HttpListenerHost)_httpServer).ServiceController.RestPathMap.OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase).ToList(); - - foreach (var current in all) - { - foreach (var info in current.Value) - { - if (info.IsHidden) - { - continue; - } - - if (info.Path.StartsWith("/mediabrowser", StringComparison.OrdinalIgnoreCase) - || info.Path.StartsWith("/jellyfin", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - paths[info.Path] = GetPathInfo(info); - } - } - - return paths; - } - - private Dictionary<string, SwaggerMethod> GetPathInfo(RestPath info) - { - var result = new Dictionary<string, SwaggerMethod>(); - - foreach (var verb in info.Verbs) - { - var responses = new Dictionary<string, SwaggerResponse> - { - { "200", new SwaggerResponse { description = "OK" } } - }; - - var apiKeySecurity = new Dictionary<string, string[]> - { - { "api_key", Array.Empty<string>() } - }; - - result[verb.ToLowerInvariant()] = new SwaggerMethod - { - summary = info.Summary, - description = info.Description, - produces = new[] { "application/json" }, - consumes = new[] { "application/json" }, - operationId = info.RequestType.Name, - tags = Array.Empty<string>(), - - parameters = Array.Empty<SwaggerParam>(), - - responses = responses, - - security = new[] { apiKeySecurity } - }; - } - - return result; - } - } -} diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 78cc02e99..8a8223ee7 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -848,8 +848,8 @@ namespace Emby.Server.Implementations.Session /// </summary> /// <param name="info">The info.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">info</exception> - /// <exception cref="ArgumentOutOfRangeException">positionTicks</exception> + /// <exception cref="ArgumentNullException"><c>info</c> is <c>null</c>.</exception> + /// <exception cref="ArgumentOutOfRangeException"><c>info.PositionTicks</c> is <c>null</c> or negative.</exception> public async Task OnPlaybackStopped(PlaybackStopInfo info) { CheckDisposed(); diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index b9db6ecd0..8bebd37dc 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -93,7 +93,7 @@ namespace Emby.Server.Implementations.Session if (session != null) { EnsureController(session, e.Argument); - await KeepAliveWebSocket(e.Argument); + await KeepAliveWebSocket(e.Argument).ConfigureAwait(false); } else { @@ -177,7 +177,7 @@ namespace Emby.Server.Implementations.Session // Notify WebSocket about timeout try { - await SendForceKeepAlive(webSocket); + await SendForceKeepAlive(webSocket).ConfigureAwait(false); } catch (WebSocketException exception) { @@ -233,6 +233,7 @@ namespace Emby.Server.Implementations.Session if (_keepAliveCancellationToken != null) { _keepAliveCancellationToken.Cancel(); + _keepAliveCancellationToken.Dispose(); _keepAliveCancellationToken = null; } } @@ -268,7 +269,7 @@ namespace Emby.Server.Implementations.Session lost = _webSockets.Where(i => (DateTime.UtcNow - i.LastKeepAliveDate).TotalSeconds >= WebSocketLostTimeout).ToList(); } - if (inactive.Any()) + if (inactive.Count > 0) { _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); } @@ -277,7 +278,7 @@ namespace Emby.Server.Implementations.Session { try { - await SendForceKeepAlive(webSocket); + await SendForceKeepAlive(webSocket).ConfigureAwait(false); } catch (WebSocketException exception) { @@ -288,7 +289,7 @@ namespace Emby.Server.Implementations.Session lock (_webSocketsLock) { - if (lost.Any()) + if (lost.Count > 0) { _logger.LogInformation("Lost {0} WebSockets.", lost.Count); foreach (var webSocket in lost) @@ -298,7 +299,7 @@ namespace Emby.Server.Implementations.Session } } - if (!_webSockets.Any()) + if (_webSockets.Count == 0) { StopKeepAlive(); } @@ -312,11 +313,13 @@ namespace Emby.Server.Implementations.Session /// <returns>Task.</returns> private Task SendForceKeepAlive(IWebSocketConnection webSocket) { - return webSocket.SendAsync(new WebSocketMessage<int> - { - MessageType = "ForceKeepAlive", - Data = WebSocketLostTimeout - }, CancellationToken.None); + return webSocket.SendAsync( + new WebSocketMessage<int> + { + MessageType = "ForceKeepAlive", + Data = WebSocketLostTimeout + }, + CancellationToken.None); } /// <summary> @@ -330,12 +333,11 @@ namespace Emby.Server.Implementations.Session { while (!cancellationToken.IsCancellationRequested) { - await callback(); - Task task = Task.Delay(interval, cancellationToken); + await callback().ConfigureAwait(false); try { - await task; + await Task.Delay(interval, cancellationToken).ConfigureAwait(false); } catch (TaskCanceledException) { diff --git a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs index 2b7d818be..1f68a9c81 100644 --- a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs +++ b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs @@ -154,8 +154,8 @@ namespace Emby.Server.Implementations.Sorting private static int CompareEpisodes(Episode x, Episode y) { - var xValue = (x.ParentIndexNumber ?? -1) * 1000 + (x.IndexNumber ?? -1); - var yValue = (y.ParentIndexNumber ?? -1) * 1000 + (y.IndexNumber ?? -1); + var xValue = ((x.ParentIndexNumber ?? -1) * 1000) + (x.IndexNumber ?? -1); + var yValue = ((y.ParentIndexNumber ?? -1) * 1000) + (y.IndexNumber ?? -1); return xValue.CompareTo(yValue); } diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs index 39d17833f..80b977731 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -27,14 +28,17 @@ namespace Emby.Server.Implementations.SyncPlay /// All sessions will receive the message. /// </summary> AllGroup = 0, + /// <summary> /// Only the specified session will receive the message. /// </summary> CurrentSession = 1, + /// <summary> /// All sessions, except the current one, will receive the message. /// </summary> AllExceptCurrentSession = 2, + /// <summary> /// Only sessions that are not buffering will receive the message. /// </summary> @@ -56,15 +60,6 @@ namespace Emby.Server.Implementations.SyncPlay /// </summary> private readonly GroupInfo _group = new GroupInfo(); - /// <inheritdoc /> - public Guid GetGroupId() => _group.GroupId; - - /// <inheritdoc /> - public Guid GetPlayingItemId() => _group.PlayingItem.Id; - - /// <inheritdoc /> - public bool IsGroupEmpty() => _group.IsEmpty(); - /// <summary> /// Initializes a new instance of the <see cref="SyncPlayController" /> class. /// </summary> @@ -78,6 +73,15 @@ namespace Emby.Server.Implementations.SyncPlay _syncPlayManager = syncPlayManager; } + /// <inheritdoc /> + public Guid GetGroupId() => _group.GroupId; + + /// <inheritdoc /> + public Guid GetPlayingItemId() => _group.PlayingItem.Id; + + /// <inheritdoc /> + public bool IsGroupEmpty() => _group.IsEmpty(); + /// <summary> /// Converts DateTime to UTC string. /// </summary> @@ -85,7 +89,7 @@ namespace Emby.Server.Implementations.SyncPlay /// <value>The UTC string.</value> private string DateToUTCString(DateTime date) { - return date.ToUniversalTime().ToString("o"); + return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); } /// <summary> @@ -94,23 +98,23 @@ namespace Emby.Server.Implementations.SyncPlay /// <param name="from">The current session.</param> /// <param name="type">The filtering type.</param> /// <value>The array of sessions matching the filter.</value> - private SessionInfo[] FilterSessions(SessionInfo from, BroadcastType type) + private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type) { switch (type) { case BroadcastType.CurrentSession: return new SessionInfo[] { from }; case BroadcastType.AllGroup: - return _group.Participants.Values.Select( - session => session.Session).ToArray(); + return _group.Participants.Values + .Select(session => session.Session); case BroadcastType.AllExceptCurrentSession: - return _group.Participants.Values.Select( - session => session.Session).Where( - session => !session.Id.Equals(from.Id)).ToArray(); + return _group.Participants.Values + .Select(session => session.Session) + .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal)); case BroadcastType.AllReady: - return _group.Participants.Values.Where( - session => !session.IsBuffering).Select( - session => session.Session).ToArray(); + return _group.Participants.Values + .Where(session => !session.IsBuffering) + .Select(session => session.Session); default: return Array.Empty<SessionInfo>(); } @@ -128,10 +132,9 @@ namespace Emby.Server.Implementations.SyncPlay { IEnumerable<Task> GetTasks() { - SessionInfo[] sessions = FilterSessions(from, type); - foreach (var session in sessions) + foreach (var session in FilterSessions(from, type)) { - yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), message, cancellationToken); + yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken); } } @@ -150,10 +153,9 @@ namespace Emby.Server.Implementations.SyncPlay { IEnumerable<Task> GetTasks() { - SessionInfo[] sessions = FilterSessions(from, type); - foreach (var session in sessions) + foreach (var session in FilterSessions(from, type)) { - yield return _sessionManager.SendSyncPlayCommand(session.Id.ToString(), message, cancellationToken); + yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken); } } @@ -236,9 +238,11 @@ namespace Emby.Server.Implementations.SyncPlay } else { - var playRequest = new PlayRequest(); - playRequest.ItemIds = new Guid[] { _group.PlayingItem.Id }; - playRequest.StartPositionTicks = _group.PositionTicks; + var playRequest = new PlayRequest + { + ItemIds = new Guid[] { _group.PlayingItem.Id }, + StartPositionTicks = _group.PositionTicks + }; var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest); SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken); } diff --git a/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs new file mode 100644 index 000000000..2fdd1e489 --- /dev/null +++ b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Identifies an action that supports the HTTP GET method. + /// </summary> + public class HttpSubscribeAttribute : HttpMethodAttribute + { + private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" }; + + /// <summary> + /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. + /// </summary> + public HttpSubscribeAttribute() + : base(_supportedMethods) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. + /// </summary> + /// <param name="template">The route template. May not be null.</param> + public HttpSubscribeAttribute(string template) + : base(_supportedMethods, template) + { + if (template == null) + { + throw new ArgumentNullException(nameof(template)); + } + } + } +} diff --git a/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs new file mode 100644 index 000000000..d6d7e4563 --- /dev/null +++ b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Identifies an action that supports the HTTP GET method. + /// </summary> + public class HttpUnsubscribeAttribute : HttpMethodAttribute + { + private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" }; + + /// <summary> + /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. + /// </summary> + public HttpUnsubscribeAttribute() + : base(_supportedMethods) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. + /// </summary> + /// <param name="template">The route template. May not be null.</param> + public HttpUnsubscribeAttribute(string template) + : base(_supportedMethods, template) + { + if (template == null) + { + throw new ArgumentNullException(nameof(template)); + } + } + } +} diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs new file mode 100644 index 000000000..aa366f567 --- /dev/null +++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs @@ -0,0 +1,103 @@ +using System.Security.Claims; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth +{ + /// <summary> + /// Base authorization handler. + /// </summary> + /// <typeparam name="T">Type of Authorization Requirement.</typeparam> + public abstract class BaseAuthorizationHandler<T> : AuthorizationHandler<T> + where T : IAuthorizationRequirement + { + private readonly IUserManager _userManager; + private readonly INetworkManager _networkManager; + private readonly IHttpContextAccessor _httpContextAccessor; + + /// <summary> + /// Initializes a new instance of the <see cref="BaseAuthorizationHandler{T}"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + protected BaseAuthorizationHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + { + _userManager = userManager; + _networkManager = networkManager; + _httpContextAccessor = httpContextAccessor; + } + + /// <summary> + /// Validate authenticated claims. + /// </summary> + /// <param name="claimsPrincipal">Request claims.</param> + /// <param name="ignoreSchedule">Whether to ignore parental control.</param> + /// <param name="localAccessOnly">Whether access is to be allowed locally only.</param> + /// <param name="requiredDownloadPermission">Whether validation requires download permission.</param> + /// <returns>Validated claim status.</returns> + protected bool ValidateClaims( + ClaimsPrincipal claimsPrincipal, + bool ignoreSchedule = false, + bool localAccessOnly = false, + bool requiredDownloadPermission = false) + { + // Ensure claim has userId. + var userId = ClaimHelpers.GetUserId(claimsPrincipal); + if (!userId.HasValue) + { + return false; + } + + // Ensure userId links to a valid user. + var user = _userManager.GetUserById(userId.Value); + if (user == null) + { + return false; + } + + // Ensure user is not disabled. + if (user.HasPermission(PermissionKind.IsDisabled)) + { + return false; + } + + var ip = RequestHelpers.NormalizeIp(_httpContextAccessor.HttpContext.Connection.RemoteIpAddress).ToString(); + var isInLocalNetwork = _networkManager.IsInLocalNetwork(ip); + // User cannot access remotely and user is remote + if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork) + { + return false; + } + + if (localAccessOnly && !isInLocalNetwork) + { + return false; + } + + // User attempting to access out of parental control hours. + if (!ignoreSchedule + && !user.HasPermission(PermissionKind.IsAdministrator) + && !user.IsParentalScheduleAllowed()) + { + return false; + } + + // User attempting to download without permission. + if (requiredDownloadPermission + && !user.HasPermission(PermissionKind.EnableContentDownloading)) + { + return false; + } + + return true; + } + } +} diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index f86f75b1c..733c6959e 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Authentication; using System.Security.Claims; using System.Text.Encodings.Web; @@ -44,14 +45,24 @@ namespace Jellyfin.Api.Auth var authorizationInfo = _authService.Authenticate(Request); if (authorizationInfo == null) { - return Task.FromResult(AuthenticateResult.Fail("Invalid user")); + 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")); } 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.Role, authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User), + 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), }; + var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs new file mode 100644 index 000000000..b5913daab --- /dev/null +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy +{ + /// <summary> + /// Default authorization handler. + /// </summary> + public class DefaultAuthorizationHandler : BaseAuthorizationHandler<DefaultAuthorizationRequirement> + { + /// <summary> + /// Initializes a new instance of the <see cref="DefaultAuthorizationHandler"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public DefaultAuthorizationHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement) + { + var validated = ValidateClaims(context.User); + if (!validated) + { + context.Fail(); + return Task.CompletedTask; + } + + context.Succeed(requirement); + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs new file mode 100644 index 000000000..7cea00b69 --- /dev/null +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy +{ + /// <summary> + /// The default authorization requirement. + /// </summary> + public class DefaultAuthorizationRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs b/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs new file mode 100644 index 000000000..b61680ab1 --- /dev/null +++ b/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.DownloadPolicy +{ + /// <summary> + /// Download authorization handler. + /// </summary> + public class DownloadHandler : BaseAuthorizationHandler<DownloadRequirement> + { + /// <summary> + /// Initializes a new instance of the <see cref="DownloadHandler"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public DownloadHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DownloadRequirement requirement) + { + var validated = ValidateClaims(context.User); + if (validated) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs b/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs new file mode 100644 index 000000000..b0a72a9de --- /dev/null +++ b/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.DownloadPolicy +{ + /// <summary> + /// The download permission requirement. + /// </summary> + public class DownloadRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs b/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs new file mode 100644 index 000000000..31482a930 --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy +{ + /// <summary> + /// Ignore parental control schedule and allow before startup wizard has been completed. + /// </summary> + public class FirstTimeOrIgnoreParentalControlSetupHandler : BaseAuthorizationHandler<FirstTimeOrIgnoreParentalControlSetupRequirement> + { + private readonly IConfigurationManager _configurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="FirstTimeOrIgnoreParentalControlSetupHandler"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + public FirstTimeOrIgnoreParentalControlSetupHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor, + IConfigurationManager configurationManager) + : base(userManager, networkManager, httpContextAccessor) + { + _configurationManager = configurationManager; + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeOrIgnoreParentalControlSetupRequirement requirement) + { + if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + var validated = ValidateClaims(context.User, ignoreSchedule: true); + if (validated) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs b/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs new file mode 100644 index 000000000..00aaec334 --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy +{ + /// <summary> + /// First time setup or ignore parental controls requirement. + /// </summary> + public class FirstTimeOrIgnoreParentalControlSetupRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs new file mode 100644 index 000000000..9815e252e --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy +{ + /// <summary> + /// Authorization handler for requiring first time setup or default privileges. + /// </summary> + public class FirstTimeSetupOrDefaultHandler : BaseAuthorizationHandler<FirstTimeSetupOrDefaultRequirement> + { + private readonly IConfigurationManager _configurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="FirstTimeSetupOrDefaultHandler" /> class. + /// </summary> + /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public FirstTimeSetupOrDefaultHandler( + IConfigurationManager configurationManager, + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + _configurationManager = configurationManager; + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrDefaultRequirement firstTimeSetupOrDefaultRequirement) + { + if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) + { + context.Succeed(firstTimeSetupOrDefaultRequirement); + return Task.CompletedTask; + } + + var validated = ValidateClaims(context.User); + if (validated) + { + context.Succeed(firstTimeSetupOrDefaultRequirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs new file mode 100644 index 000000000..f7366bd7a --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy +{ + /// <summary> + /// The authorization requirement, requiring incomplete first time setup or default privileges, for the authorization handler. + /// </summary> + public class FirstTimeSetupOrDefaultRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs index 34aa5d12c..decbe0c03 100644 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs @@ -1,22 +1,33 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy { /// <summary> /// Authorization handler for requiring first time setup or elevated privileges. /// </summary> - public class FirstTimeSetupOrElevatedHandler : AuthorizationHandler<FirstTimeSetupOrElevatedRequirement> + public class FirstTimeSetupOrElevatedHandler : BaseAuthorizationHandler<FirstTimeSetupOrElevatedRequirement> { private readonly IConfigurationManager _configurationManager; /// <summary> /// Initializes a new instance of the <see cref="FirstTimeSetupOrElevatedHandler" /> class. /// </summary> - /// <param name="configurationManager">The jellyfin configuration manager.</param> - public FirstTimeSetupOrElevatedHandler(IConfigurationManager configurationManager) + /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public FirstTimeSetupOrElevatedHandler( + IConfigurationManager configurationManager, + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) { _configurationManager = configurationManager; } @@ -27,8 +38,11 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) { context.Succeed(firstTimeSetupOrElevatedRequirement); + return Task.CompletedTask; } - else if (context.User.IsInRole(UserRoles.Administrator)) + + var validated = ValidateClaims(context.User); + if (validated && context.User.IsInRole(UserRoles.Administrator)) { context.Succeed(firstTimeSetupOrElevatedRequirement); } diff --git a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs new file mode 100644 index 000000000..5213bc4cb --- /dev/null +++ b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy +{ + /// <summary> + /// Escape schedule controls handler. + /// </summary> + public class IgnoreParentalControlHandler : BaseAuthorizationHandler<IgnoreParentalControlRequirement> + { + /// <summary> + /// Initializes a new instance of the <see cref="IgnoreParentalControlHandler"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public IgnoreParentalControlHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement) + { + var validated = ValidateClaims(context.User, ignoreSchedule: true); + if (!validated) + { + context.Fail(); + return Task.CompletedTask; + } + + context.Succeed(requirement); + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs new file mode 100644 index 000000000..cdad74270 --- /dev/null +++ b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy +{ + /// <summary> + /// Escape schedule controls requirement. + /// </summary> + public class IgnoreParentalControlRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs new file mode 100644 index 000000000..14722aa57 --- /dev/null +++ b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy +{ + /// <summary> + /// Local access or require elevated privileges handler. + /// </summary> + public class LocalAccessOrRequiresElevationHandler : BaseAuthorizationHandler<LocalAccessOrRequiresElevationRequirement> + { + /// <summary> + /// Initializes a new instance of the <see cref="LocalAccessOrRequiresElevationHandler"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public LocalAccessOrRequiresElevationHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessOrRequiresElevationRequirement requirement) + { + var validated = ValidateClaims(context.User, localAccessOnly: true); + if (validated || context.User.IsInRole(UserRoles.Administrator)) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs new file mode 100644 index 000000000..d9c64d01c --- /dev/null +++ b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy +{ + /// <summary> + /// The local access or elevated privileges authorization requirement. + /// </summary> + public class LocalAccessOrRequiresElevationRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs new file mode 100644 index 000000000..af73352bc --- /dev/null +++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.LocalAccessPolicy +{ + /// <summary> + /// Local access handler. + /// </summary> + public class LocalAccessHandler : BaseAuthorizationHandler<LocalAccessRequirement> + { + /// <summary> + /// Initializes a new instance of the <see cref="LocalAccessHandler"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public LocalAccessHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement) + { + var validated = ValidateClaims(context.User, localAccessOnly: true); + if (!validated) + { + context.Fail(); + } + else + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs new file mode 100644 index 000000000..761127fa4 --- /dev/null +++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.LocalAccessPolicy +{ + /// <summary> + /// The local access authorization requirement. + /// </summary> + public class LocalAccessRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs index 2d3bb1aa4..b235c4b63 100644 --- a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs +++ b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs @@ -1,21 +1,43 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; namespace Jellyfin.Api.Auth.RequiresElevationPolicy { /// <summary> /// Authorization handler for requiring elevated privileges. /// </summary> - public class RequiresElevationHandler : AuthorizationHandler<RequiresElevationRequirement> + public class RequiresElevationHandler : BaseAuthorizationHandler<RequiresElevationRequirement> { + /// <summary> + /// Initializes a new instance of the <see cref="RequiresElevationHandler"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public RequiresElevationHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement) { - if (context.User.IsInRole(UserRoles.Administrator)) + var validated = ValidateClaims(context.User); + if (validated && context.User.IsInRole(UserRoles.Administrator)) { context.Succeed(requirement); } + else + { + context.Fail(); + } return Task.CompletedTask; } diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs new file mode 100644 index 000000000..4d7c7135d --- /dev/null +++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs @@ -0,0 +1,38 @@ +namespace Jellyfin.Api.Constants +{ + /// <summary> + /// Internal claim types for authorization. + /// </summary> + public static class InternalClaimTypes + { + /// <summary> + /// User Id. + /// </summary> + public const string UserId = "Jellyfin-UserId"; + + /// <summary> + /// Device Id. + /// </summary> + public const string DeviceId = "Jellyfin-DeviceId"; + + /// <summary> + /// Device. + /// </summary> + public const string Device = "Jellyfin-Device"; + + /// <summary> + /// Client. + /// </summary> + public const string Client = "Jellyfin-Client"; + + /// <summary> + /// Version. + /// </summary> + public const string Version = "Jellyfin-Version"; + + /// <summary> + /// Token. + /// </summary> + public const string Token = "Jellyfin-Token"; + } +} diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index e2b383f75..7d7767470 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -6,13 +6,48 @@ namespace Jellyfin.Api.Constants public static class Policies { /// <summary> + /// Policy name for default authorization. + /// </summary> + public const string DefaultAuthorization = "DefaultAuthorization"; + + /// <summary> /// Policy name for requiring first time setup or elevated privileges. /// </summary> - public const string FirstTimeSetupOrElevated = "FirstTimeOrElevated"; + public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated"; /// <summary> /// Policy name for requiring elevated privileges. /// </summary> public const string RequiresElevation = "RequiresElevation"; + + /// <summary> + /// Policy name for allowing local access only. + /// </summary> + public const string LocalAccessOnly = "LocalAccessOnly"; + + /// <summary> + /// Policy name for escaping schedule controls. + /// </summary> + public const string IgnoreParentalControl = "IgnoreParentalControl"; + + /// <summary> + /// Policy name for requiring download permission. + /// </summary> + public const string Download = "Download"; + + /// <summary> + /// Policy name for requiring first time setup or default permissions. + /// </summary> + public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault"; + + /// <summary> + /// Policy name for requiring local access or elevated privileges. + /// </summary> + public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation"; + + /// <summary> + /// Policy name for escaping schedule controls or requiring first time setup. + /// </summary> + public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; } } diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs new file mode 100644 index 000000000..a07cea9c0 --- /dev/null +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Data.Entities; +using MediaBrowser.Model.Activity; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Activity log controller. + /// </summary> + [Route("System/ActivityLog")] + [Authorize(Policy = Policies.RequiresElevation)] + public class ActivityLogController : BaseJellyfinApiController + { + private readonly IActivityManager _activityManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ActivityLogController"/> class. + /// </summary> + /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param> + public ActivityLogController(IActivityManager activityManager) + { + _activityManager = activityManager; + } + + /// <summary> + /// Gets activity log entries. + /// </summary> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="minDate">Optional. The minimum date. Format = ISO.</param> + /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param> + /// <response code="200">Activity log returned.</response> + /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns> + [HttpGet("Entries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] DateTime? minDate, + [FromQuery] bool? hasUserId) + { + var filterFunc = new Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>>( + entries => entries.Where(entry => entry.DateCreated >= minDate + && (!hasUserId.HasValue || (hasUserId.Value + ? entry.UserId != Guid.Empty + : entry.UserId == Guid.Empty)))); + + return _activityManager.GetPagedResult(filterFunc, startIndex, limit); + } + } +} diff --git a/Jellyfin.Api/Controllers/AlbumsController.cs b/Jellyfin.Api/Controllers/AlbumsController.cs new file mode 100644 index 000000000..190d4bd07 --- /dev/null +++ b/Jellyfin.Api/Controllers/AlbumsController.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +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 +{ + /// <summary> + /// The albums controller. + /// </summary> + [Route("")] + public class AlbumsController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + + /// <summary> + /// Initializes a new instance of the <see cref="AlbumsController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public AlbumsController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService) + { + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + } + + /// <summary> + /// Finds albums similar to a given album. + /// </summary> + /// <param name="albumId">The album id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <response code="200">Similar albums returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar albums.</returns> + [HttpGet("Albums/{albumId}/Similar")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums( + [FromRoute] 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); + } + + /// <summary> + /// Finds artists similar to a given artist. + /// </summary> + /// <param name="artistId">The artist id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <response code="200">Similar artists returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar artists.</returns> + [HttpGet("Artists/{artistId}/Similar")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists( + [FromRoute] 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); + } + + /// <summary> + /// Gets a similairty score of two albums. + /// </summary> + /// <param name="item1">The first item.</param> + /// <param name="item1People">The item1 people.</param> + /// <param name="allPeople">All people.</param> + /// <param name="item2">The second item.</param> + /// <returns>System.Int32.</returns> + private int GetAlbumSimilarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> 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<string>( + album2.GetAllArtists().DistinctNames(), + StringComparer.OrdinalIgnoreCase); + + return points + artists1.Where(artists2.Contains).Sum(i => 5); + } + } +} diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs new file mode 100644 index 000000000..0e28d4c47 --- /dev/null +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -0,0 +1,97 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Authentication controller. + /// </summary> + [Route("Auth")] + public class ApiKeyController : BaseJellyfinApiController + { + private readonly ISessionManager _sessionManager; + private readonly IServerApplicationHost _appHost; + private readonly IAuthenticationRepository _authRepo; + + /// <summary> + /// Initializes a new instance of the <see cref="ApiKeyController"/> class. + /// </summary> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="authRepo">Instance of <see cref="IAuthenticationRepository"/> interface.</param> + public ApiKeyController( + ISessionManager sessionManager, + IServerApplicationHost appHost, + IAuthenticationRepository authRepo) + { + _sessionManager = sessionManager; + _appHost = appHost; + _authRepo = authRepo; + } + + /// <summary> + /// Get all keys. + /// </summary> + /// <response code="200">Api keys retrieved.</response> + /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns> + [HttpGet("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<AuthenticationInfo>> GetKeys() + { + var result = _authRepo.Get(new AuthenticationInfoQuery + { + HasUser = false + }); + + return result; + } + + /// <summary> + /// Create a new api key. + /// </summary> + /// <param name="app">Name of the app using the authentication key.</param> + /// <response code="204">Api key created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CreateKey([FromQuery, Required] string? app) + { + _authRepo.Create(new AuthenticationInfo + { + AppName = app, + AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), + DateCreated = DateTime.UtcNow, + DeviceId = _appHost.SystemId, + DeviceName = _appHost.FriendlyName, + AppVersion = _appHost.ApplicationVersionString + }); + return NoContent(); + } + + /// <summary> + /// Remove an api key. + /// </summary> + /// <param name="key">The access token to delete.</param> + /// <response code="204">Api key deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Keys/{key}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RevokeKey([FromRoute, Required] string? key) + { + _sessionManager.RevokeToken(key); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs new file mode 100644 index 000000000..3f72830cd --- /dev/null +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -0,0 +1,488 @@ +using System; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Dto; +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; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The artists controller. + /// </summary> + [Route("Artists")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ArtistsController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + + /// <summary> + /// Initializes a new instance of the <see cref="ArtistsController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public ArtistsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } + + /// <summary> + /// Gets all artists from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">Optional. Search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Total record count.</param> + /// <response code="200">Artists returned.</response> + /// <returns>An <see cref="OkResult"/> containing the artists.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetArtists( + [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] string? 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] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions() + .AddItemFields(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 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 + }; + + 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 RequestHelpers.GetFilters(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.GetArtists(query); + + 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<BaseItemDto> + { + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; + } + + /// <summary> + /// Gets all album artists from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">Optional. Search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Total record count.</param> + /// <response code="200">Album artists returned.</response> + /// <returns>An <see cref="OkResult"/> containing the album artists.</returns> + [HttpGet("AlbumArtists")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists( + [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] string? 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] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions() + .AddItemFields(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 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 + }; + + 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 RequestHelpers.GetFilters(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.GetAlbumArtists(query); + + 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<BaseItemDto> + { + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; + } + + /// <summary> + /// Gets an artist by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Artist returned.</response> + /// <returns>An <see cref="OkResult"/> containing the artist.</returns> + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetArtistByName([FromRoute] string name, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions().AddClientFields(Request); + + var item = _libraryManager.GetArtist(name, dtoOptions); + + if (userId.HasValue && !userId.Equals(Guid.Empty)) + { + var user = _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + return _dtoService.GetBaseItemDto(item, dtoOptions); + } + } +} diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs new file mode 100644 index 000000000..4de87616c --- /dev/null +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The audio controller. + /// </summary> + // TODO: In order to autheneticate this in the future, Dlna playback will require updating + public class AudioController : BaseJellyfinApiController + { + private readonly IDlnaManager _dlnaManager; + private readonly IAuthorizationContext _authContext; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IConfiguration _configuration; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly IHttpClientFactory _httpClientFactory; + + private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; + + /// <summary> + /// Initializes a new instance of the <see cref="AudioController"/> class. + /// </summary> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="userManger">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param> + /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> + public AudioController( + IDlnaManager dlnaManager, + IUserManager userManger, + IAuthorizationContext authorizationContext, + ILibraryManager libraryManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + IHttpClientFactory httpClientFactory) + { + _dlnaManager = dlnaManager; + _authContext = authorizationContext; + _userManager = userManger; + _libraryManager = libraryManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _subtitleEncoder = subtitleEncoder; + _configuration = configuration; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _httpClientFactory = httpClientFactory; + } + + /// <summary> + /// Gets an audio stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetAudioStreamByContainer")] + [HttpGet("{itemId}/stream", Name = "GetAudioStream")] + [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadAudioStreamByContainer")] + [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetAudioStream( + [FromRoute] Guid itemId, + [FromRoute] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string>? streamOptions) + { + bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + + var cancellationTokenSource = new CancellationTokenSource(); + + StreamingRequestDto streamingRequest = new StreamingRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? true, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodingReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Static, + StreamOptions = streamOptions + }; + + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _fileSystem, + _subtitleEncoder, + _configuration, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + _transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + if (@static.HasValue && @static.Value && state.DirectStreamProvider != null) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); + + await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None) + { + AllowEndOfFile = false + }.WriteToAsync(Response.Body, CancellationToken.None) + .ConfigureAwait(false); + + // TODO (moved from MediaBrowser.Api): Don't hardcode contentType + return File(Response.Body, MimeTypes.GetMimeType("file.ts")!); + } + + // Static remote stream + if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); + + using var httpClient = _httpClientFactory.CreateClient(); + return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, httpClient).ConfigureAwait(false); + } + + if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File) + { + return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); + } + + var outputPath = state.OutputFilePath; + var outputPathExists = System.IO.File.Exists(outputPath); + + var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var isTranscodeCached = outputPathExists && transcodingJob != null; + + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, startTimeTicks, Request, _dlnaManager); + + // Static stream + if (@static.HasValue && @static.Value) + { + var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + + if (state.MediaSource.IsInfiniteStream) + { + await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None) + { + AllowEndOfFile = false + }.WriteToAsync(Response.Body, CancellationToken.None) + .ConfigureAwait(false); + + return File(Response.Body, contentType); + } + + return FileStreamResponseHelpers.GetStaticFileResult( + state.MediaPath, + contentType, + isHeadRequest, + this); + } + + // Need to start ffmpeg (because media can't be returned directly) + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + var ffmpegCommandLineArguments = encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); + return await FileStreamResponseHelpers.GetTranscodedFile( + state, + isHeadRequest, + this, + _transcodingJobHelper, + ffmpegCommandLineArguments, + Request, + _transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs new file mode 100644 index 000000000..1d4836f27 --- /dev/null +++ b/Jellyfin.Api/Controllers/BrandingController.cs @@ -0,0 +1,57 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Branding; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Branding controller. + /// </summary> + public class BrandingController : BaseJellyfinApiController + { + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="BrandingController"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public BrandingController(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// <summary> + /// Gets branding configuration. + /// </summary> + /// <response code="200">Branding configuration returned.</response> + /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BrandingOptions> GetBrandingOptions() + { + return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + } + + /// <summary> + /// Gets branding css. + /// </summary> + /// <response code="200">Branding css returned.</response> + /// <response code="204">No branding css configured.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the branding css if exist, + /// or a <see cref="NoContentResult"/> if the css is not configured. + /// </returns> + [HttpGet("Css")] + [HttpGet("Css.css", Name = "GetBrandingCss_2")] + [Produces("text/css")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult<string> GetBrandingCss() + { + var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + return options.CustomCss ?? string.Empty; + } + } +} diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs new file mode 100644 index 000000000..bdd7dfd96 --- /dev/null +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Channels; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Channels Controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ChannelsController : BaseJellyfinApiController + { + private readonly IChannelManager _channelManager; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ChannelsController"/> class. + /// </summary> + /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public ChannelsController(IChannelManager channelManager, IUserManager userManager) + { + _channelManager = channelManager; + _userManager = userManager; + } + + /// <summary> + /// Gets available channels. + /// </summary> + /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param> + /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param> + /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param> + /// <response code="200">Channels returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channels.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetChannels( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? supportsLatestItems, + [FromQuery] bool? supportsMediaDeletion, + [FromQuery] bool? isFavorite) + { + return _channelManager.GetChannels(new ChannelQuery + { + Limit = limit, + StartIndex = startIndex, + UserId = userId ?? Guid.Empty, + SupportsLatestItems = supportsLatestItems, + SupportsMediaDeletion = supportsMediaDeletion, + IsFavorite = isFavorite + }); + } + + /// <summary> + /// Get all channel features. + /// </summary> + /// <response code="200">All channel features returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> + [HttpGet("Features")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures() + { + return _channelManager.GetAllChannelFeatures(); + } + + /// <summary> + /// Get channel features. + /// </summary> + /// <param name="channelId">Channel id.</param> + /// <response code="200">Channel features returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> + [HttpGet("{channelId}/Features")] + public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute] string channelId) + { + return _channelManager.GetChannelFeatures(channelId); + } + + /// <summary> + /// Get channel items. + /// </summary> + /// <param name="channelId">Channel Id.</param> + /// <param name="folderId">Optional. Folder Id.</param> + /// <param name="userId">Optional. User Id.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="sortBy">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.</param> + /// <param name="fields">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.</param> + /// <response code="200">Channel items returned.</response> + /// <returns> + /// A <see cref="Task"/> representing the request to get the channel items. + /// The task result contains an <see cref="OkResult"/> containing the channel items. + /// </returns> + [HttpGet("{channelId}/Items")] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems( + [FromRoute] Guid channelId, + [FromQuery] Guid? folderId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? sortOrder, + [FromQuery] string? filters, + [FromQuery] string? sortBy, + [FromQuery] string? fields) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var query = new InternalItemsQuery(user) + { + Limit = limit, + StartIndex = startIndex, + ChannelIds = new[] { channelId }, + ParentId = folderId ?? Guid.Empty, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + DtoOptions = new DtoOptions() + .AddItemFields(fields) + }; + + foreach (var filter in RequestHelpers.GetFilters(filters)) + { + switch (filter) + { + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; + } + } + + return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Gets latest channel items. + /// </summary> + /// <param name="userId">Optional. User Id.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="fields">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.</param> + /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param> + /// <response code="200">Latest channel items returned.</response> + /// <returns> + /// A <see cref="Task"/> representing the request to get the latest channel items. + /// The task result contains an <see cref="OkResult"/> containing the latest channel items. + /// </returns> + [HttpGet("Items/Latest")] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? filters, + [FromQuery] string? fields, + [FromQuery] string? channelIds) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var query = new InternalItemsQuery(user) + { + Limit = limit, + StartIndex = startIndex, + ChannelIds = (channelIds ?? string.Empty) + .Split(',') + .Where(i => !string.IsNullOrWhiteSpace(i)) + .Select(i => new Guid(i)) + .ToArray(), + DtoOptions = new DtoOptions() + .AddItemFields(fields) + }; + + foreach (var filter in RequestHelpers.GetFilters(filters)) + { + switch (filter) + { + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; + } + } + + return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs new file mode 100644 index 000000000..53821a188 --- /dev/null +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -0,0 +1,111 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Collections; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The collection controller. + /// </summary> + [Route("Collections")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class CollectionController : BaseJellyfinApiController + { + private readonly ICollectionManager _collectionManager; + private readonly IDtoService _dtoService; + private readonly IAuthorizationContext _authContext; + + /// <summary> + /// Initializes a new instance of the <see cref="CollectionController"/> class. + /// </summary> + /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param> + /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> + /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param> + public CollectionController( + ICollectionManager collectionManager, + IDtoService dtoService, + IAuthorizationContext authContext) + { + _collectionManager = collectionManager; + _dtoService = dtoService; + _authContext = authContext; + } + + /// <summary> + /// Creates a new collection. + /// </summary> + /// <param name="name">The name of the collection.</param> + /// <param name="ids">Item Ids to add to the collection.</param> + /// <param name="parentId">Optional. Create the collection within a specific folder.</param> + /// <param name="isLocked">Whether or not to lock the new collection.</param> + /// <response code="200">Collection created.</response> + /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<CollectionCreationResult> CreateCollection( + [FromQuery] string? name, + [FromQuery] string? ids, + [FromQuery] Guid? parentId, + [FromQuery] bool isLocked = false) + { + var userId = _authContext.GetAuthorizationInfo(Request).UserId; + + var item = _collectionManager.CreateCollection(new CollectionCreationOptions + { + IsLocked = isLocked, + Name = name, + ParentId = parentId, + ItemIdList = RequestHelpers.Split(ids, ',', true), + UserIds = new[] { userId } + }); + + var dtoOptions = new DtoOptions().AddClientFields(Request); + + var dto = _dtoService.GetBaseItemDto(item, dtoOptions); + + return new CollectionCreationResult + { + Id = dto.Id + }; + } + + /// <summary> + /// Adds items to a collection. + /// </summary> + /// <param name="collectionId">The collection id.</param> + /// <param name="itemIds">Item ids, comma delimited.</param> + /// <response code="204">Items added to collection.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("{collectionId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddToCollection([FromRoute] Guid collectionId, [FromQuery, Required] string? itemIds) + { + _collectionManager.AddToCollection(collectionId, RequestHelpers.Split(itemIds, ',', true)); + return NoContent(); + } + + /// <summary> + /// Removes items from a collection. + /// </summary> + /// <param name="collectionId">The collection id.</param> + /// <param name="itemIds">Item ids, comma delimited.</param> + /// <response code="204">Items removed from collection.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpDelete("{collectionId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveFromCollection([FromRoute] Guid collectionId, [FromQuery, Required] string? itemIds) + { + _collectionManager.RemoveFromCollection(collectionId, RequestHelpers.Split(itemIds, ',', true)); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs new file mode 100644 index 000000000..019703dae --- /dev/null +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -0,0 +1,126 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.ConfigurationDtos; +using MediaBrowser.Common.Json; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Configuration; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Configuration Controller. + /// </summary> + [Route("System")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ConfigurationController : BaseJellyfinApiController + { + private readonly IServerConfigurationManager _configurationManager; + private readonly IMediaEncoder _mediaEncoder; + + private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.GetOptions(); + + /// <summary> + /// Initializes a new instance of the <see cref="ConfigurationController"/> class. + /// </summary> + /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + public ConfigurationController( + IServerConfigurationManager configurationManager, + IMediaEncoder mediaEncoder) + { + _configurationManager = configurationManager; + _mediaEncoder = mediaEncoder; + } + + /// <summary> + /// Gets application configuration. + /// </summary> + /// <response code="200">Application configuration returned.</response> + /// <returns>Application configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ServerConfiguration> GetConfiguration() + { + return _configurationManager.Configuration; + } + + /// <summary> + /// Updates application configuration. + /// </summary> + /// <param name="configuration">Configuration.</param> + /// <response code="204">Configuration updated.</response> + /// <returns>Update status.</returns> + [HttpPost("Configuration")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration) + { + _configurationManager.ReplaceConfiguration(configuration); + return NoContent(); + } + + /// <summary> + /// Gets a named configuration. + /// </summary> + /// <param name="key">Configuration key.</param> + /// <response code="200">Configuration returned.</response> + /// <returns>Configuration.</returns> + [HttpGet("Configuration/{key}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<object> GetNamedConfiguration([FromRoute] string? key) + { + return _configurationManager.GetConfiguration(key); + } + + /// <summary> + /// Updates named configuration. + /// </summary> + /// <param name="key">Configuration key.</param> + /// <response code="204">Named configuration updated.</response> + /// <returns>Update status.</returns> + [HttpPost("Configuration/{key}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string? key) + { + var configurationType = _configurationManager.GetConfigurationType(key); + var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType, _serializerOptions).ConfigureAwait(false); + _configurationManager.SaveConfiguration(key, configuration); + return NoContent(); + } + + /// <summary> + /// Gets a default MetadataOptions object. + /// </summary> + /// <response code="200">Metadata options returned.</response> + /// <returns>Default MetadataOptions.</returns> + [HttpGet("Configuration/MetadataOptions/Default")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<MetadataOptions> GetDefaultMetadataOptions() + { + return new MetadataOptions(); + } + + /// <summary> + /// Updates the path to the media encoder. + /// </summary> + /// <param name="mediaEncoderPath">Media encoder path form body.</param> + /// <response code="204">Media encoder path updated.</response> + /// <returns>Status.</returns> + [HttpPost("MediaEncoder/Path")] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaEncoderPath([FromForm, Required] MediaEncoderPathDto mediaEncoderPath) + { + _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs new file mode 100644 index 000000000..33abe3ccd --- /dev/null +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Jellyfin.Api.Models; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Extensions; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Plugins; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The dashboard controller. + /// </summary> + [Route("")] + public class DashboardController : BaseJellyfinApiController + { + private readonly ILogger<DashboardController> _logger; + private readonly IServerApplicationHost _appHost; + private readonly IConfiguration _appConfig; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IResourceFileManager _resourceFileManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DashboardController"/> class. + /// </summary> + /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param> + /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="appConfig">Instance of <see cref="IConfiguration"/> interface.</param> + /// <param name="resourceFileManager">Instance of <see cref="IResourceFileManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + public DashboardController( + ILogger<DashboardController> logger, + IServerApplicationHost appHost, + IConfiguration appConfig, + IResourceFileManager resourceFileManager, + IServerConfigurationManager serverConfigurationManager) + { + _logger = logger; + _appHost = appHost; + _appConfig = appConfig; + _resourceFileManager = resourceFileManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <summary> + /// Gets the path of the directory containing the static web interface content, or null if the server is not + /// hosting the web client. + /// </summary> + private string? WebClientUiPath => GetWebClientUiPath(_appConfig, _serverConfigurationManager); + + /// <summary> + /// Gets the configuration pages. + /// </summary> + /// <param name="enableInMainMenu">Whether to enable in the main menu.</param> + /// <param name="pageType">The <see cref="ConfigurationPageInfo"/>.</param> + /// <response code="200">ConfigurationPages returned.</response> + /// <response code="404">Server still loading.</response> + /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns> + [HttpGet("web/ConfigurationPages")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ConfigurationPageInfo?>> GetConfigurationPages( + [FromQuery] bool? enableInMainMenu, + [FromQuery] ConfigurationPageType? pageType) + { + const string unavailableMessage = "The server is still loading. Please try again momentarily."; + + var pages = _appHost.GetExports<IPluginConfigurationPage>().ToList(); + + if (pages == null) + { + return NotFound(unavailableMessage); + } + + // Don't allow a failing plugin to fail them all + var configPages = pages.Select(p => + { + try + { + return new ConfigurationPageInfo(p); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting plugin information from {Plugin}", p.GetType().Name); + return null; + } + }) + .Where(i => i != null) + .ToList(); + + configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages)); + + if (pageType.HasValue) + { + configPages = configPages.Where(p => p!.ConfigurationPageType == pageType).ToList(); + } + + if (enableInMainMenu.HasValue) + { + configPages = configPages.Where(p => p!.EnableInMainMenu == enableInMainMenu.Value).ToList(); + } + + return configPages; + } + + /// <summary> + /// Gets a dashboard configuration page. + /// </summary> + /// <param name="name">The name of the page.</param> + /// <response code="200">ConfigurationPage returned.</response> + /// <response code="404">Plugin configuration page not found.</response> + /// <returns>The configuration page.</returns> + [HttpGet("web/ConfigurationPage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetDashboardConfigurationPage([FromQuery] string? name) + { + IPlugin? plugin = null; + Stream? stream = null; + + var isJs = false; + var isTemplate = false; + + var page = _appHost.GetExports<IPluginConfigurationPage>().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + if (page != null) + { + plugin = page.Plugin; + stream = page.GetHtmlStream(); + } + + if (plugin == null) + { + var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase)); + if (altPage != null) + { + plugin = altPage.Item2; + stream = plugin.GetType().Assembly.GetManifestResourceStream(altPage.Item1.EmbeddedResourcePath); + + isJs = string.Equals(Path.GetExtension(altPage.Item1.EmbeddedResourcePath), ".js", StringComparison.OrdinalIgnoreCase); + isTemplate = altPage.Item1.EmbeddedResourcePath.EndsWith(".template.html", StringComparison.Ordinal); + } + } + + if (plugin != null && stream != null) + { + if (isJs) + { + return File(stream, MimeTypes.GetMimeType("page.js")); + } + + if (isTemplate) + { + return File(stream, MimeTypes.GetMimeType("page.html")); + } + + return File(stream, MimeTypes.GetMimeType("page.html")); + } + + return NotFound(); + } + + /// <summary> + /// Gets the robots.txt. + /// </summary> + /// <response code="200">Robots.txt returned.</response> + /// <returns>The robots.txt.</returns> + [HttpGet("robots.txt")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ApiExplorerSettings(IgnoreApi = true)] + public ActionResult GetRobotsTxt() + { + return GetWebClientResource("robots.txt"); + } + + /// <summary> + /// Gets a resource from the web client. + /// </summary> + /// <param name="resourceName">The resource name.</param> + /// <response code="200">Web client returned.</response> + /// <response code="404">Server does not host a web client.</response> + /// <returns>The resource.</returns> + [HttpGet("web/{*resourceName}")] + [ApiExplorerSettings(IgnoreApi = true)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetWebClientResource([FromRoute] string resourceName) + { + if (!_appConfig.HostWebClient() || WebClientUiPath == null) + { + return NotFound("Server does not host a web client."); + } + + var path = resourceName; + var basePath = WebClientUiPath; + + var requestPathAndQuery = Request.GetEncodedPathAndQuery(); + // Bounce them to the startup wizard if it hasn't been completed yet + if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted + && !requestPathAndQuery.Contains("wizard", StringComparison.OrdinalIgnoreCase) + && requestPathAndQuery.Contains("index", StringComparison.OrdinalIgnoreCase)) + { + return Redirect("index.html?start=wizard#!/wizardstart.html"); + } + + var stream = new FileStream(_resourceFileManager.GetResourcePath(basePath, path), FileMode.Open, FileAccess.Read); + return File(stream, MimeTypes.GetMimeType(path)); + } + + /// <summary> + /// Gets the favicon. + /// </summary> + /// <response code="200">Favicon.ico returned.</response> + /// <returns>The favicon.</returns> + [HttpGet("favicon.ico")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ApiExplorerSettings(IgnoreApi = true)] + public ActionResult GetFavIcon() + { + return GetWebClientResource("favicon.ico"); + } + + /// <summary> + /// Gets the path of the directory containing the static web interface content. + /// </summary> + /// <param name="appConfig">The app configuration.</param> + /// <param name="serverConfigManager">The server configuration manager.</param> + /// <returns>The directory path, or null if the server is not hosting the web client.</returns> + public static string? GetWebClientUiPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager) + { + if (!appConfig.HostWebClient()) + { + return null; + } + + if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath)) + { + return serverConfigManager.Configuration.DashboardSourcePath; + } + + return serverConfigManager.ApplicationPaths.WebPath; + } + + private IEnumerable<ConfigurationPageInfo> GetConfigPages(IPlugin plugin) + { + return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1)); + } + + private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(IPlugin plugin) + { + if (!(plugin is IHasWebPages hasWebPages)) + { + return new List<Tuple<PluginPageInfo, IPlugin>>(); + } + + return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin)); + } + + private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages() + { + return _appHost.Plugins.SelectMany(GetPluginPages); + } + } +} diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs new file mode 100644 index 000000000..23d10e215 --- /dev/null +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -0,0 +1,155 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Devices Controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class DevicesController : BaseJellyfinApiController + { + private readonly IDeviceManager _deviceManager; + private readonly IAuthenticationRepository _authenticationRepository; + private readonly ISessionManager _sessionManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DevicesController"/> class. + /// </summary> + /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> + /// <param name="authenticationRepository">Instance of <see cref="IAuthenticationRepository"/> interface.</param> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + public DevicesController( + IDeviceManager deviceManager, + IAuthenticationRepository authenticationRepository, + ISessionManager sessionManager) + { + _deviceManager = deviceManager; + _authenticationRepository = authenticationRepository; + _sessionManager = sessionManager; + } + + /// <summary> + /// Get Devices. + /// </summary> + /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param> + /// <param name="userId">Gets or sets the user identifier.</param> + /// <response code="200">Devices retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns> + [HttpGet] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery, Required] Guid? userId) + { + var deviceQuery = new DeviceQuery { SupportsSync = supportsSync, UserId = userId ?? Guid.Empty }; + return _deviceManager.GetDevices(deviceQuery); + } + + /// <summary> + /// Get info for a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="200">Device info retrieved.</response> + /// <response code="404">Device not found.</response> + /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpGet("Info")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, Required] string? id) + { + var deviceInfo = _deviceManager.GetDevice(id); + if (deviceInfo == null) + { + return NotFound(); + } + + return deviceInfo; + } + + /// <summary> + /// Get options for a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="200">Device options retrieved.</response> + /// <response code="404">Device not found.</response> + /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpGet("Options")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, Required] string? id) + { + var deviceInfo = _deviceManager.GetDeviceOptions(id); + if (deviceInfo == null) + { + return NotFound(); + } + + return deviceInfo; + } + + /// <summary> + /// Update device options. + /// </summary> + /// <param name="id">Device Id.</param> + /// <param name="deviceOptions">Device Options.</param> + /// <response code="204">Device options updated.</response> + /// <response code="404">Device not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpPost("Options")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateDeviceOptions( + [FromQuery, Required] string? id, + [FromBody, Required] DeviceOptions deviceOptions) + { + var existingDeviceOptions = _deviceManager.GetDeviceOptions(id); + if (existingDeviceOptions == null) + { + return NotFound(); + } + + _deviceManager.UpdateDeviceOptions(id, deviceOptions); + return NoContent(); + } + + /// <summary> + /// Deletes a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="204">Device deleted.</response> + /// <response code="404">Device not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteDevice([FromQuery, Required] string? id) + { + var existingDevice = _deviceManager.GetDevice(id); + if (existingDevice == null) + { + return NotFound(); + } + + var sessions = _authenticationRepository.Get(new AuthenticationInfoQuery { DeviceId = id }).Items; + + foreach (var session in sessions) + { + _sessionManager.Logout(session); + } + + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs new file mode 100644 index 000000000..c547d0cde --- /dev/null +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -0,0 +1,176 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller; +using MediaBrowser.Model.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Display Preferences Controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class DisplayPreferencesController : BaseJellyfinApiController + { + private readonly IDisplayPreferencesManager _displayPreferencesManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class. + /// </summary> + /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param> + public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager) + { + _displayPreferencesManager = displayPreferencesManager; + } + + /// <summary> + /// Get Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User id.</param> + /// <param name="client">Client.</param> + /// <response code="200">Display preferences retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns> + [HttpGet("{displayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] + public ActionResult<DisplayPreferencesDto> GetDisplayPreferences( + [FromRoute] string? displayPreferencesId, + [FromQuery] [Required] Guid userId, + [FromQuery] [Required] string? client) + { + var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client); + var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client); + + var dto = new DisplayPreferencesDto + { + Client = displayPreferences.Client, + Id = displayPreferences.UserId.ToString(), + ViewType = itemPreferences.ViewType.ToString(), + SortBy = itemPreferences.SortBy, + SortOrder = itemPreferences.SortOrder, + IndexBy = displayPreferences.IndexBy?.ToString(), + RememberIndexing = itemPreferences.RememberIndexing, + RememberSorting = itemPreferences.RememberSorting, + ScrollDirection = displayPreferences.ScrollDirection, + ShowBackdrop = displayPreferences.ShowBackdrop, + ShowSidebar = displayPreferences.ShowSidebar + }; + + foreach (var homeSection in displayPreferences.HomeSections) + { + dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant(); + } + + foreach (var itemDisplayPreferences in _displayPreferencesManager.ListItemDisplayPreferences(displayPreferences.UserId, displayPreferences.Client)) + { + dto.CustomPrefs["landing-" + itemDisplayPreferences.ItemId] = itemDisplayPreferences.ViewType.ToString().ToLowerInvariant(); + } + + dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant(); + dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture); + dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture); + dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture); + dto.CustomPrefs["tvhome"] = displayPreferences.TvHome; + + return dto; + } + + /// <summary> + /// Update Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User Id.</param> + /// <param name="client">Client.</param> + /// <param name="displayPreferences">New Display Preferences object.</param> + /// <response code="204">Display preferences updated.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{displayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] + public ActionResult UpdateDisplayPreferences( + [FromRoute] string? displayPreferencesId, + [FromQuery, Required] Guid userId, + [FromQuery, Required] string? client, + [FromBody, Required] DisplayPreferencesDto displayPreferences) + { + HomeSectionType[] defaults = + { + HomeSectionType.SmallLibraryTiles, + HomeSectionType.Resume, + HomeSectionType.ResumeAudio, + HomeSectionType.LiveTv, + HomeSectionType.NextUp, + HomeSectionType.LatestMedia, HomeSectionType.None, + }; + + var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client); + existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null; + existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; + existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; + + existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection; + existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion) + ? Enum.Parse<ChromecastVersion>(chromecastVersion, true) + : ChromecastVersion.Stable; + existingDisplayPreferences.EnableNextVideoInfoOverlay = displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay) + ? bool.Parse(enableNextVideoInfoOverlay) + : true; + existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) + ? int.Parse(skipBackLength, CultureInfo.InvariantCulture) + : 10000; + existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength) + ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture) + : 30000; + existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme) + ? theme + : string.Empty; + existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home) + ? home + : string.Empty; + existingDisplayPreferences.HomeSections.Clear(); + + foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase))) + { + var order = int.Parse(key.AsSpan().Slice("homesection".Length)); + if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type)) + { + type = order < 7 ? defaults[order] : HomeSectionType.None; + } + + existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type }); + } + + foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) + { + var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Parse(key.Substring("landing-".Length)), existingDisplayPreferences.Client); + itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType); + _displayPreferencesManager.SaveChanges(itemPreferences); + } + + var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Empty, existingDisplayPreferences.Client); + itemPrefs.SortBy = displayPreferences.SortBy; + itemPrefs.SortOrder = displayPreferences.SortOrder; + itemPrefs.RememberIndexing = displayPreferences.RememberIndexing; + itemPrefs.RememberSorting = displayPreferences.RememberSorting; + + if (Enum.TryParse<ViewType>(displayPreferences.ViewType, true, out var viewType)) + { + itemPrefs.ViewType = viewType; + } + + _displayPreferencesManager.SaveChanges(existingDisplayPreferences); + _displayPreferencesManager.SaveChanges(itemPrefs); + + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/DlnaController.cs b/Jellyfin.Api/Controllers/DlnaController.cs new file mode 100644 index 000000000..397299a73 --- /dev/null +++ b/Jellyfin.Api/Controllers/DlnaController.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Model.Dlna; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Dlna Controller. + /// </summary> + [Authorize(Policy = Policies.RequiresElevation)] + public class DlnaController : BaseJellyfinApiController + { + private readonly IDlnaManager _dlnaManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DlnaController"/> class. + /// </summary> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + public DlnaController(IDlnaManager dlnaManager) + { + _dlnaManager = dlnaManager; + } + + /// <summary> + /// Get profile infos. + /// </summary> + /// <response code="200">Device profile infos returned.</response> + /// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns> + [HttpGet("ProfileInfos")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos() + { + return Ok(_dlnaManager.GetProfileInfos()); + } + + /// <summary> + /// Gets the default profile. + /// </summary> + /// <response code="200">Default device profile returned.</response> + /// <returns>An <see cref="OkResult"/> containing the default profile.</returns> + [HttpGet("Profiles/Default")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<DeviceProfile> GetDefaultProfile() + { + return _dlnaManager.GetDefaultProfile(); + } + + /// <summary> + /// Gets a single profile. + /// </summary> + /// <param name="profileId">Profile Id.</param> + /// <response code="200">Device profile returned.</response> + /// <response code="404">Device profile not found.</response> + /// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns> + [HttpGet("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<DeviceProfile> GetProfile([FromRoute] string profileId) + { + var profile = _dlnaManager.GetProfile(profileId); + if (profile == null) + { + return NotFound(); + } + + return profile; + } + + /// <summary> + /// Deletes a profile. + /// </summary> + /// <param name="profileId">Profile id.</param> + /// <response code="204">Device profile deleted.</response> + /// <response code="404">Device profile not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> + [HttpDelete("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteProfile([FromRoute] string profileId) + { + var existingDeviceProfile = _dlnaManager.GetProfile(profileId); + if (existingDeviceProfile == null) + { + return NotFound(); + } + + _dlnaManager.DeleteProfile(profileId); + return NoContent(); + } + + /// <summary> + /// Creates a profile. + /// </summary> + /// <param name="deviceProfile">Device profile.</param> + /// <response code="204">Device profile created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Profiles")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile) + { + _dlnaManager.CreateProfile(deviceProfile); + return NoContent(); + } + + /// <summary> + /// Updates a profile. + /// </summary> + /// <param name="profileId">Profile id.</param> + /// <param name="deviceProfile">Device profile.</param> + /// <response code="204">Device profile updated.</response> + /// <response code="404">Device profile not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> + [HttpPost("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateProfile([FromRoute] string profileId, [FromBody] DeviceProfile deviceProfile) + { + var existingDeviceProfile = _dlnaManager.GetProfile(profileId); + if (existingDeviceProfile == null) + { + return NotFound(); + } + + _dlnaManager.UpdateProfile(deviceProfile); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs new file mode 100644 index 000000000..ef507f2ed --- /dev/null +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -0,0 +1,257 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading.Tasks; +using Emby.Dlna; +using Emby.Dlna.Main; +using Jellyfin.Api.Attributes; +using MediaBrowser.Controller.Dlna; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Dlna Server Controller. + /// </summary> + [Route("Dlna")] + public class DlnaServerController : BaseJellyfinApiController + { + private const string XMLContentType = "text/xml; charset=UTF-8"; + + private readonly IDlnaManager _dlnaManager; + private readonly IContentDirectory _contentDirectory; + private readonly IConnectionManager _connectionManager; + private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar; + + /// <summary> + /// Initializes a new instance of the <see cref="DlnaServerController"/> class. + /// </summary> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + public DlnaServerController(IDlnaManager dlnaManager) + { + _dlnaManager = dlnaManager; + _contentDirectory = DlnaEntryPoint.Current.ContentDirectory; + _connectionManager = DlnaEntryPoint.Current.ConnectionManager; + _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar; + } + + /// <summary> + /// Get Description Xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Description xml returned.</response> + /// <returns>An <see cref="OkResult"/> containing the description xml.</returns> + [HttpGet("{serverId}/description")] + [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")] + [Produces(XMLContentType)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetDescriptionXml([FromRoute] string serverId) + { + var url = GetAbsoluteUri(); + var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase)); + var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress); + return Ok(xml); + } + + /// <summary> + /// Gets Dlna content directory xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna content directory returned.</response> + /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns> + [HttpGet("{serverId}/ContentDirectory/ContentDirectory")] + [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_2")] + [Produces(XMLContentType)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult GetContentDirectory([FromRoute] string serverId) + { + return Ok(_contentDirectory.GetServiceXml()); + } + + /// <summary> + /// Gets Dlna media receiver registrar xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <returns>Dlna media receiver registrar xml.</returns> + [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar")] + [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_2")] + [Produces(XMLContentType)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult GetMediaReceiverRegistrar([FromRoute] string serverId) + { + return Ok(_mediaReceiverRegistrar.GetServiceXml()); + } + + /// <summary> + /// Gets Dlna media receiver registrar xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <returns>Dlna media receiver registrar xml.</returns> + [HttpGet("{serverId}/ConnectionManager/ConnectionManager")] + [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_2")] + [Produces(XMLContentType)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult GetConnectionManager([FromRoute] string serverId) + { + return Ok(_connectionManager.GetServiceXml()); + } + + /// <summary> + /// Process a content directory control request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <returns>Control response.</returns> + [HttpPost("{serverId}/ContentDirectory/Control")] + public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); + } + + /// <summary> + /// Process a connection manager control request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <returns>Control response.</returns> + [HttpPost("{serverId}/ConnectionManager/Control")] + public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); + } + + /// <summary> + /// Process a media receiver registrar control request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <returns>Control response.</returns> + [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] + public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); + } + + /// <summary> + /// Processes an event subscription request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <returns>Event subscription response.</returns> + [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")] + public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId) + { + return ProcessEventRequest(_mediaReceiverRegistrar); + } + + /// <summary> + /// Processes an event subscription request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <returns>Event subscription response.</returns> + [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")] + public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId) + { + return ProcessEventRequest(_contentDirectory); + } + + /// <summary> + /// Processes an event subscription request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <returns>Event subscription response.</returns> + [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")] + public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId) + { + return ProcessEventRequest(_connectionManager); + } + + /// <summary> + /// Gets a server icon. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <param name="fileName">The icon filename.</param> + /// <returns>Icon stream.</returns> + [HttpGet("{serverId}/icons/{fileName}")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult GetIconId([FromRoute] string serverId, [FromRoute] string fileName) + { + return GetIconInternal(fileName); + } + + /// <summary> + /// Gets a server icon. + /// </summary> + /// <param name="fileName">The icon filename.</param> + /// <returns>Icon stream.</returns> + [HttpGet("icons/{fileName}")] + public ActionResult GetIcon([FromRoute] string fileName) + { + return GetIconInternal(fileName); + } + + private ActionResult GetIconInternal(string fileName) + { + var icon = _dlnaManager.GetIcon(fileName); + if (icon == null) + { + return NotFound(); + } + + var contentType = "image/" + Path.GetExtension(fileName) + .TrimStart('.') + .ToLowerInvariant(); + + return File(icon.Stream, contentType); + } + + private string GetAbsoluteUri() + { + return $"{Request.Scheme}://{Request.Host}{Request.Path}"; + } + + private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service) + { + return service.ProcessControlRequestAsync(new ControlRequest + { + Headers = Request.Headers, + InputXml = requestStream, + TargetServerUuId = id, + RequestedUrl = GetAbsoluteUri() + }); + } + + private EventSubscriptionResponse ProcessEventRequest(IEventManager eventManager) + { + var subscriptionId = Request.Headers["SID"]; + if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase)) + { + var notificationType = Request.Headers["NT"]; + var callback = Request.Headers["CALLBACK"]; + var timeoutString = Request.Headers["TIMEOUT"]; + + if (string.IsNullOrEmpty(notificationType)) + { + return eventManager.RenewEventSubscription( + subscriptionId, + notificationType, + timeoutString, + callback); + } + + return eventManager.CreateEventSubscription(notificationType, timeoutString, callback); + } + + return eventManager.CancelEventSubscription(subscriptionId); + } + } +} diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs new file mode 100644 index 000000000..d581ab8cd --- /dev/null +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -0,0 +1,2221 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.PlaybackDtos; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Dynamic hls controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class DynamicHlsController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDlnaManager _dlnaManager; + private readonly IAuthorizationContext _authContext; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IConfiguration _configuration; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly INetworkManager _networkManager; + private readonly ILogger<DynamicHlsController> _logger; + private readonly EncodingHelper _encodingHelper; + + private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls; + + /// <summary> + /// Initializes a new instance of the <see cref="DynamicHlsController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param> + public DynamicHlsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDlnaManager dlnaManager, + IAuthorizationContext authContext, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + INetworkManager networkManager, + ILogger<DynamicHlsController> logger) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dlnaManager = dlnaManager; + _authContext = authContext; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _subtitleEncoder = subtitleEncoder; + _configuration = configuration; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _networkManager = networkManager; + _logger = logger; + + _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + } + + /// <summary> + /// Gets a video hls playlist stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> + [HttpGet("Videos/{itemId}/master.m3u8")] + [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetMasterHlsVideoPlaylist( + [FromRoute] Guid itemId, + [FromRoute] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery, Required] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAdaptiveBitrateStreaming = true) + { + var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new HlsVideoRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? true, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodingReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context, + StreamOptions = streamOptions, + EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming + }; + + return await GetMasterPlaylistInternal(streamingRequest, isHeadRequest, enableAdaptiveBitrateStreaming, cancellationTokenSource) + .ConfigureAwait(false); + } + + /// <summary> + /// Gets an audio hls playlist stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> + [HttpGet("Audio/{itemId}/master.m3u8")] + [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetMasterHlsAudioPlaylist( + [FromRoute] Guid itemId, + [FromRoute] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery, Required] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAdaptiveBitrateStreaming = true) + { + var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new HlsAudioRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? true, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodingReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context, + StreamOptions = streamOptions, + EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming + }; + + return await GetMasterPlaylistInternal(streamingRequest, isHeadRequest, enableAdaptiveBitrateStreaming, cancellationTokenSource) + .ConfigureAwait(false); + } + + /// <summary> + /// Gets a video stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("Videos/{itemId}/main.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetVariantHlsVideoPlaylist( + [FromRoute] Guid itemId, + [FromRoute] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new VideoRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? true, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodingReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context, + StreamOptions = streamOptions + }; + + return await GetVariantPlaylistInternal(streamingRequest, "main", cancellationTokenSource) + .ConfigureAwait(false); + } + + /// <summary> + /// Gets an audio stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("Audio/{itemId}/main.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetVariantHlsAudioPlaylist( + [FromRoute] Guid itemId, + [FromRoute] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new StreamingRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? true, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodingReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context, + StreamOptions = streamOptions + }; + + return await GetVariantPlaylistInternal(streamingRequest, "main", cancellationTokenSource) + .ConfigureAwait(false); + } + + /// <summary> + /// Gets a video stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <param name="segmentId">The segment id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> GetHlsVideoSegment( + [FromRoute] Guid itemId, + [FromRoute] string playlistId, + [FromRoute] int segmentId, + [FromRoute] string container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var streamingRequest = new VideoRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? true, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodingReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context, + StreamOptions = streamOptions + }; + + return await GetDynamicSegment(streamingRequest, segmentId) + .ConfigureAwait(false); + } + + /// <summary> + /// Gets a video stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <param name="segmentId">The segment id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> GetHlsAudioSegment( + [FromRoute] Guid itemId, + [FromRoute] string playlistId, + [FromRoute] int segmentId, + [FromRoute] string container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var streamingRequest = new StreamingRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? true, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodingReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context, + StreamOptions = streamOptions + }; + + return await GetDynamicSegment(streamingRequest, segmentId) + .ConfigureAwait(false); + } + + private async Task<ActionResult> GetMasterPlaylistInternal( + StreamingRequestDto streamingRequest, + bool isHeadRequest, + bool enableAdaptiveBitrateStreaming, + CancellationTokenSource cancellationTokenSource) + { + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _fileSystem, + _subtitleEncoder, + _configuration, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + _transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + Response.Headers.Add(HeaderNames.Expires, "0"); + if (isHeadRequest) + { + return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8")); + } + + var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0; + + var builder = new StringBuilder(); + + builder.AppendLine("#EXTM3U"); + + var isLiveStream = state.IsSegmentedLiveStream; + + var queryString = Request.QueryString.ToString(); + + // from universal audio service + if (queryString.IndexOf("SegmentContainer", StringComparison.OrdinalIgnoreCase) == -1 && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)) + { + queryString += "&SegmentContainer=" + state.Request.SegmentContainer; + } + + // from universal audio service + if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) && queryString.IndexOf("TranscodeReasons=", StringComparison.OrdinalIgnoreCase) == -1) + { + queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; + } + + // Main stream + var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; + + playlistUrl += queryString; + + var subtitleStreams = state.MediaSource + .MediaStreams + .Where(i => i.IsTextSubtitleStream) + .ToList(); + + var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest) + ? "subs" + : null; + + // If we're burning in subtitles then don't add additional subs to the manifest + if (state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + { + subtitleGroup = null; + } + + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + AddSubtitles(state, subtitleStreams, builder); + } + + AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + + if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming)) + { + var requestedVideoBitrate = state.VideoRequest == null ? 0 : state.VideoRequest.VideoBitRate ?? 0; + + // By default, vary by just 200k + var variation = GetBitrateVariation(totalBitrate); + + var newBitrate = totalBitrate - variation; + var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + + variation *= 2; + newBitrate = totalBitrate - variation; + variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + } + + return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } + + private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, string name, CancellationTokenSource cancellationTokenSource) + { + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _fileSystem, + _subtitleEncoder, + _configuration, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + _transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + Response.Headers.Add(HeaderNames.Expires, "0"); + + var segmentLengths = GetSegmentLengths(state); + + var builder = new StringBuilder(); + + builder.AppendLine("#EXTM3U"); + builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); + builder.AppendLine("#EXT-X-VERSION:3"); + builder.AppendLine("#EXT-X-TARGETDURATION:" + Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength).ToString(CultureInfo.InvariantCulture)); + builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); + + var queryString = Request.QueryString; + var index = 0; + + var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer); + + foreach (var length in segmentLengths) + { + builder.AppendLine("#EXTINF:" + length.ToString("0.0000", CultureInfo.InvariantCulture) + ", nodesc"); + builder.AppendLine( + string.Format( + CultureInfo.InvariantCulture, + "hls1/{0}/{1}{2}{3}", + name, + index.ToString(CultureInfo.InvariantCulture), + segmentExtension, + queryString)); + + index++; + } + + builder.AppendLine("#EXT-X-ENDLIST"); + return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } + + private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId) + { + if ((streamingRequest.StartTimeTicks ?? 0) > 0) + { + throw new ArgumentException("StartTimeTicks is not allowed."); + } + + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _fileSystem, + _subtitleEncoder, + _configuration, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + _transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); + + var segmentPath = GetSegmentPath(state, playlistPath, segmentId); + + var segmentExtension = GetSegmentFileExtension(state.Request.SegmentContainer); + + TranscodingJobDto? job; + + if (System.IO.File.Exists(segmentPath)) + { + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + } + + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); + await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + var released = false; + var startTranscoding = false; + + try + { + if (System.IO.File.Exists(segmentPath)) + { + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + transcodingLock.Release(); + released = true; + _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + } + else + { + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; + + if (currentTranscodingIndex == null) + { + _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); + startTranscoding = true; + } + else if (segmentId < currentTranscodingIndex.Value) + { + _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); + startTranscoding = true; + } + else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) + { + _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); + startTranscoding = true; + } + + if (startTranscoding) + { + // If the playlist doesn't already exist, startup ffmpeg + try + { + await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) + .ConfigureAwait(false); + + if (currentTranscodingIndex.HasValue) + { + DeleteLastFile(playlistPath, segmentExtension, 0); + } + + streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId); + + state.WaitForPath = segmentPath; + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + job = await _transcodingJobHelper.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId), + Request, + _transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + catch + { + state.Dispose(); + throw; + } + + // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + } + else + { + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + if (job?.TranscodingThrottler != null) + { + await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); + } + } + } + } + finally + { + if (!released) + { + transcodingLock.Release(); + } + } + + _logger.LogDebug("returning {0} [general case]", segmentPath); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + } + + private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder) + { + var selectedIndex = state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; + const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; + + foreach (var stream in subtitles) + { + var name = stream.DisplayTitle; + + var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index; + var isForced = stream.IsForced; + + var url = string.Format( + CultureInfo.InvariantCulture, + "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", + state.Request.MediaSourceId, + stream.Index.ToString(CultureInfo.InvariantCulture), + 30.ToString(CultureInfo.InvariantCulture), + ClaimHelpers.GetToken(Request.HttpContext.User)); + + var line = string.Format( + CultureInfo.InvariantCulture, + Format, + name, + isDefault ? "YES" : "NO", + isForced ? "YES" : "NO", + url, + stream.Language ?? "Unknown"); + + builder.AppendLine(line); + } + } + + private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + { + builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)) + .Append(",AVERAGE-BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)); + + AppendPlaylistCodecsField(builder, state); + + AppendPlaylistResolutionField(builder, state); + + AppendPlaylistFramerateField(builder, state); + + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + builder.Append(",SUBTITLES=\"") + .Append(subtitleGroup) + .Append('"'); + } + + builder.Append(Environment.NewLine); + builder.AppendLine(url); + } + + /// <summary> + /// Appends a CODECS field containing formatted strings of + /// the active streams output video and audio codecs. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> + /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state) + { + // Video + string videoCodecs = string.Empty; + int? videoCodecLevel = GetOutputVideoCodecLevel(state); + if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue) + { + videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value); + } + + // Audio + string audioCodecs = string.Empty; + if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) + { + audioCodecs = GetPlaylistAudioCodecs(state); + } + + StringBuilder codecs = new StringBuilder(); + + codecs.Append(videoCodecs); + + if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs)) + { + codecs.Append(','); + } + + codecs.Append(audioCodecs); + + if (codecs.Length > 1) + { + builder.Append(",CODECS=\"") + .Append(codecs) + .Append('"'); + } + } + + /// <summary> + /// Appends a RESOLUTION field containing the resolution of the output stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state) + { + if (state.OutputWidth.HasValue && state.OutputHeight.HasValue) + { + builder.Append(",RESOLUTION=") + .Append(state.OutputWidth.GetValueOrDefault()) + .Append('x') + .Append(state.OutputHeight.GetValueOrDefault()); + } + } + + /// <summary> + /// Appends a FRAME-RATE field containing the framerate of the output stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state) + { + double? framerate = null; + if (state.TargetFramerate.HasValue) + { + framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3); + } + else if (state.VideoStream?.RealFrameRate != null) + { + framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3); + } + + if (framerate.HasValue) + { + builder.Append(",FRAME-RATE=") + .Append(framerate.Value); + } + } + + private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming) + { + // Within the local network this will likely do more harm than good. + var ip = RequestHelpers.NormalizeIp(Request.HttpContext.Connection.RemoteIpAddress).ToString(); + if (_networkManager.IsInLocalNetwork(ip)) + { + return false; + } + + if (!enableAdaptiveBitrateStreaming) + { + return false; + } + + if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath)) + { + // Opening live streams is so slow it's not even worth it + return false; + } + + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + return false; + } + + if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec)) + { + return false; + } + + if (!state.IsOutputVideo) + { + return false; + } + + // Having problems in android + return false; + // return state.VideoRequest.VideoBitRate.HasValue; + } + + /// <summary> + /// Get the H.26X level of the output video stream. + /// </summary> + /// <param name="state">StreamState of the current stream.</param> + /// <returns>H.26X level of the output video stream.</returns> + private int? GetOutputVideoCodecLevel(StreamState state) + { + string? levelString; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.Level.HasValue) + { + levelString = state.VideoStream?.Level.ToString(); + } + else + { + levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec); + } + + if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) + { + return parsedLevel; + } + + return null; + } + + /// <summary> + /// Gets a formatted string of the output audio codec, for use in the CODECS field. + /// </summary> + /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> + /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> + /// <param name="state">StreamState of the current stream.</param> + /// <returns>Formatted audio codec string.</returns> + private string GetPlaylistAudioCodecs(StreamState state) + { + if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) + { + string? profile = state.GetRequestedProfiles("aac").FirstOrDefault(); + return HlsCodecStringHelpers.GetAACString(profile); + } + + if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetMP3String(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetAC3String(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetEAC3String(); + } + + return string.Empty; + } + + /// <summary> + /// Gets a formatted string of the output video codec, for use in the CODECS field. + /// </summary> + /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> + /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="codec">Video codec.</param> + /// <param name="level">Video level.</param> + /// <returns>Formatted video codec string.</returns> + private string GetPlaylistVideoCodecs(StreamState state, string codec, int level) + { + if (level == 0) + { + // This is 0 when there's no requested H.26X level in the device profile + // and the source is not encoded in H.26X + _logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist"); + return string.Empty; + } + + if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) + { + string profile = state.GetRequestedProfiles("h264").FirstOrDefault(); + return HlsCodecStringHelpers.GetH264String(profile, level); + } + + if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + string profile = state.GetRequestedProfiles("h265").FirstOrDefault(); + + return HlsCodecStringHelpers.GetH265String(profile, level); + } + + return string.Empty; + } + + private int GetBitrateVariation(int bitrate) + { + // By default, vary by just 50k + var variation = 50000; + + if (bitrate >= 10000000) + { + variation = 2000000; + } + else if (bitrate >= 5000000) + { + variation = 1500000; + } + else if (bitrate >= 3000000) + { + variation = 1000000; + } + else if (bitrate >= 2000000) + { + variation = 500000; + } + else if (bitrate >= 1000000) + { + variation = 300000; + } + else if (bitrate >= 600000) + { + variation = 200000; + } + else if (bitrate >= 400000) + { + variation = 100000; + } + + return variation; + } + + private string ReplaceBitrate(string url, int oldValue, int newValue) + { + return url.Replace( + "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), + "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), + StringComparison.OrdinalIgnoreCase); + } + + private double[] GetSegmentLengths(StreamState state) + { + var result = new List<double>(); + + var ticks = state.RunTimeTicks ?? 0; + + var segmentLengthTicks = TimeSpan.FromSeconds(state.SegmentLength).Ticks; + + while (ticks > 0) + { + var length = ticks >= segmentLengthTicks ? segmentLengthTicks : ticks; + + result.Add(TimeSpan.FromTicks(length).TotalSeconds); + + ticks -= length; + } + + return result.ToArray(); + } + + private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber) + { + var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); + + var threads = _encodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); + + if (state.BaseRequest.BreakOnNonKeyFrames) + { + // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe + // breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable + // to produce a missing part of video stream before first keyframe is encountered, which may lead to + // awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js + _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request"); + state.BaseRequest.BreakOnNonKeyFrames = false; + } + + var inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions); + + // If isEncoding is true we're actually starting ffmpeg + var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0"; + + var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; + + var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request.SegmentContainer); + + var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); + if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) + { + segmentFormat = "mpegts"; + } + + return string.Format( + CultureInfo.InvariantCulture, + "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -f hls -max_delay 5000000 -hls_time {6} -individual_header_trailer 0 -hls_segment_type {7} -start_number {8} -hls_segment_filename \"{9}\" -hls_playlist_type vod -hls_list_size 0 -y \"{10}\"", + inputModifier, + _encodingHelper.GetInputArgument(state, encodingOptions), + threads, + mapArgs, + GetVideoArguments(state, encodingOptions, startNumber), + GetAudioArguments(state, encodingOptions), + state.SegmentLength.ToString(CultureInfo.InvariantCulture), + segmentFormat, + startNumberParam, + outputTsArg, + outputPath).Trim(); + } + + private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions) + { + var audioCodec = _encodingHelper.GetAudioEncoder(state); + + if (!state.IsOutputVideo) + { + if (EncodingHelper.IsCopyCodec(audioCodec)) + { + return "-acodec copy"; + } + + var audioTranscodeParams = new List<string>(); + + audioTranscodeParams.Add("-acodec " + audioCodec); + + if (state.OutputAudioBitrate.HasValue) + { + audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (state.OutputAudioChannels.HasValue) + { + audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (state.OutputAudioSampleRate.HasValue) + { + audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)); + } + + audioTranscodeParams.Add("-vn"); + return string.Join(' ', audioTranscodeParams); + } + + if (EncodingHelper.IsCopyCodec(audioCodec)) + { + var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); + + if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) + { + return "-codec:a:0 copy -copypriorss:a:0 0"; + } + + return "-codec:a:0 copy"; + } + + var args = "-codec:a:0 " + audioCodec; + + var channels = state.OutputAudioChannels; + + if (channels.HasValue) + { + args += " -ac " + channels.Value; + } + + var bitrate = state.OutputAudioBitrate; + + if (bitrate.HasValue) + { + args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); + } + + if (state.OutputAudioSampleRate.HasValue) + { + args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } + + args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true); + + return args; + } + + private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber) + { + if (!state.IsOutputVideo) + { + return string.Empty; + } + + var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions); + + var args = "-codec:v:0 " + codec; + + // if (state.EnableMpegtsM2TsMode) + // { + // args += " -mpegts_m2ts_mode 1"; + // } + + // See if we can save come cpu cycles by avoiding encoding + if (EncodingHelper.IsCopyCodec(codec)) + { + if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) + { + string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); + if (!string.IsNullOrEmpty(bitStreamArgs)) + { + args += " " + bitStreamArgs; + } + } + + // args += " -flags -global_header"; + } + else + { + var gopArg = string.Empty; + var keyFrameArg = string.Format( + CultureInfo.InvariantCulture, + " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"", + startNumber * state.SegmentLength, + state.SegmentLength); + + var framerate = state.VideoStream?.RealFrameRate; + + if (framerate.HasValue) + { + // This is to make sure keyframe interval is limited to our segment, + // as forcing keyframes is not enough. + // Example: we encoded half of desired length, then codec detected + // scene cut and inserted a keyframe; next forced keyframe would + // be created outside of segment, which breaks seeking + // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe + gopArg = string.Format( + CultureInfo.InvariantCulture, + " -g {0} -keyint_min {0} -sc_threshold 0", + Math.Ceiling(state.SegmentLength * framerate.Value)); + } + + args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast"); + + // Unable to force key frames using these hw encoders, set key frames by GOP + if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)) + { + args += " " + gopArg; + } + else + { + args += " " + keyFrameArg + gopArg; + } + + // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; + + var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + + // This is for graphical subs + if (hasGraphicalSubs) + { + args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec); + } + + // Add resolution params, if specified + else + { + args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec); + } + + // -start_at_zero is necessary to use with -ss when seeking, + // otherwise the target position cannot be determined. + if (!(state.SubtitleStream != null && state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)) + { + args += " -start_at_zero"; + } + + // args += " -flags -global_header"; + } + + if (!string.IsNullOrEmpty(state.OutputVideoSync)) + { + args += " -vsync " + state.OutputVideoSync; + } + + args += _encodingHelper.GetOutputFFlags(state); + + return args; + } + + private string GetSegmentFileExtension(string? segmentContainer) + { + if (!string.IsNullOrWhiteSpace(segmentContainer)) + { + return "." + segmentContainer; + } + + return ".ts"; + } + + private string GetSegmentPath(StreamState state, string playlist, int index) + { + var folder = Path.GetDirectoryName(playlist); + + var filename = Path.GetFileNameWithoutExtension(playlist); + + return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + GetSegmentFileExtension(state.Request.SegmentContainer)); + } + + private async Task<ActionResult> GetSegmentResult( + StreamState state, + string playlistPath, + string segmentPath, + string segmentExtension, + int segmentIndex, + TranscodingJobDto? transcodingJob, + CancellationToken cancellationToken) + { + var segmentExists = System.IO.File.Exists(segmentPath); + if (segmentExists) + { + if (transcodingJob != null && transcodingJob.HasExited) + { + // Transcoding job is over, so assume all existing files are ready + _logger.LogDebug("serving up {0} as transcode is over", segmentPath); + return GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob); + } + + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + + // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready + if (segmentIndex < currentTranscodingIndex) + { + _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex); + return GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob); + } + } + + var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1); + if (transcodingJob != null) + { + while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited) + { + // To be considered ready, the segment file has to exist AND + // either the transcoding job should be done or next segment should also exist + if (segmentExists) + { + if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath)) + { + _logger.LogDebug("serving up {0} as it deemed ready", segmentPath); + return GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob); + } + } + else + { + segmentExists = System.IO.File.Exists(segmentPath); + if (segmentExists) + { + continue; // avoid unnecessary waiting if segment just became available + } + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + if (!System.IO.File.Exists(segmentPath)) + { + _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath); + } + else + { + _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath); + } + + cancellationToken.ThrowIfCancellationRequested(); + } + else + { + _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath); + } + + return GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob); + } + + private ActionResult GetSegmentResult(StreamState state, string segmentPath, int index, TranscodingJobDto? transcodingJob) + { + var segmentEndingPositionTicks = GetEndPositionTicks(state, index); + + Response.OnCompleted(() => + { + _logger.LogDebug("finished serving {0}", segmentPath); + if (transcodingJob != null) + { + transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); + _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); + } + + return Task.CompletedTask; + }); + + return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)!, false, this); + } + + private long GetEndPositionTicks(StreamState state, int requestedIndex) + { + double startSeconds = 0; + var lengths = GetSegmentLengths(state); + + if (requestedIndex >= lengths.Length) + { + var msg = string.Format( + CultureInfo.InvariantCulture, + "Invalid segment index requested: {0} - Segment count: {1}", + requestedIndex, + lengths.Length); + throw new ArgumentException(msg); + } + + for (var i = 0; i <= requestedIndex; i++) + { + startSeconds += lengths[i]; + } + + return TimeSpan.FromSeconds(startSeconds).Ticks; + } + + private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) + { + var job = _transcodingJobHelper.GetTranscodingJob(playlist, _transcodingJobType); + + if (job == null || job.HasExited) + { + return null; + } + + var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem); + + if (file == null) + { + return null; + } + + var playlistFilename = Path.GetFileNameWithoutExtension(playlist); + + var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length); + + return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture); + } + + private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem) + { + var folder = Path.GetDirectoryName(playlist); + + var filePrefix = Path.GetFileNameWithoutExtension(playlist) ?? string.Empty; + + try + { + return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false) + .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(fileSystem.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + catch (IOException) + { + return null; + } + } + + private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + { + var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); + + if (file != null) + { + DeleteFile(file.FullName, retryCount); + } + } + + private void DeleteFile(string path, int retryCount) + { + if (retryCount >= 5) + { + return; + } + + _logger.LogDebug("Deleting partial HLS file {path}", path); + + try + { + _fileSystem.DeleteFile(path); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {path}", path); + + var task = Task.Delay(100); + Task.WaitAll(task); + DeleteFile(path, retryCount + 1); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {path}", path); + } + } + + private long GetStartPositionTicks(StreamState state, int requestedIndex) + { + double startSeconds = 0; + var lengths = GetSegmentLengths(state); + + if (requestedIndex >= lengths.Length) + { + var msg = string.Format( + CultureInfo.InvariantCulture, + "Invalid segment index requested: {0} - Segment count: {1}", + requestedIndex, + lengths.Length); + throw new ArgumentException(msg); + } + + for (var i = 0; i < requestedIndex; i++) + { + startSeconds += lengths[i]; + } + + var position = TimeSpan.FromSeconds(startSeconds).Ticks; + return position; + } + } +} diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs new file mode 100644 index 000000000..64670f7d8 --- /dev/null +++ b/Jellyfin.Api/Controllers/EnvironmentController.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.EnvironmentDtos; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Environment Controller. + /// </summary> + [Authorize(Policy = Policies.RequiresElevation)] + public class EnvironmentController : BaseJellyfinApiController + { + private const char UncSeparator = '\\'; + private const string UncStartPrefix = @"\\"; + + private readonly IFileSystem _fileSystem; + private readonly ILogger<EnvironmentController> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="EnvironmentController"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param> + public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger) + { + _fileSystem = fileSystem; + _logger = logger; + } + + /// <summary> + /// Gets the contents of a given directory in the file system. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param> + /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param> + /// <response code="200">Directory contents returned.</response> + /// <returns>Directory contents.</returns> + [HttpGet("DirectoryContents")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FileSystemEntryInfo> GetDirectoryContents( + [FromQuery, Required] string path, + [FromQuery] bool includeFiles = false, + [FromQuery] bool includeDirectories = false) + { + if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase) + && path.LastIndexOf(UncSeparator) == 1) + { + return Array.Empty<FileSystemEntryInfo>(); + } + + var entries = + _fileSystem.GetFileSystemEntries(path) + .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles)) + .OrderBy(i => i.FullName); + + return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File)); + } + + /// <summary> + /// Validates path. + /// </summary> + /// <param name="validatePathDto">Validate request object.</param> + /// <response code="200">Path validated.</response> + /// <response code="404">Path not found.</response> + /// <returns>Validation status.</returns> + [HttpPost("ValidatePath")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto) + { + if (validatePathDto.IsFile.HasValue) + { + if (validatePathDto.IsFile.Value) + { + if (!System.IO.File.Exists(validatePathDto.Path)) + { + return NotFound(); + } + } + else + { + if (!Directory.Exists(validatePathDto.Path)) + { + return NotFound(); + } + } + } + else + { + if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path)) + { + return NotFound(); + } + + if (validatePathDto.ValidateWritable) + { + var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString()); + try + { + System.IO.File.WriteAllText(file, string.Empty); + } + finally + { + if (System.IO.File.Exists(file)) + { + System.IO.File.Delete(file); + } + } + } + } + + return Ok(); + } + + /// <summary> + /// Gets network paths. + /// </summary> + /// <response code="200">Empty array returned.</response> + /// <returns>List of entries.</returns> + [Obsolete("This endpoint is obsolete.")] + [HttpGet("NetworkShares")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares() + { + _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares"); + return Array.Empty<FileSystemEntryInfo>(); + } + + /// <summary> + /// Gets available drives from the server's file system. + /// </summary> + /// <response code="200">List of entries returned.</response> + /// <returns>List of entries.</returns> + [HttpGet("Drives")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FileSystemEntryInfo> GetDrives() + { + return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory)); + } + + /// <summary> + /// Gets the parent path of a given path. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>Parent path.</returns> + [HttpGet("ParentPath")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<string?> GetParentPath([FromQuery, Required] string path) + { + string? parent = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(parent)) + { + // Check if unc share + var index = path.LastIndexOf(UncSeparator); + + if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0) + { + parent = path.Substring(0, index); + + if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator))) + { + parent = null; + } + } + } + + return parent; + } + + /// <summary> + /// Get Default directory browser. + /// </summary> + /// <response code="200">Default directory browser returned.</response> + /// <returns>Default directory browser.</returns> + [HttpGet("DefaultDirectoryBrowser")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser() + { + return new DefaultDirectoryBrowserInfoDto(); + } + } +} diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs new file mode 100644 index 000000000..2a567c846 --- /dev/null +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -0,0 +1,218 @@ +using System; +using System.Linq; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Filters controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class FilterController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="FilterController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public FilterController(ILibraryManager libraryManager, IUserManager userManager) + { + _libraryManager = libraryManager; + _userManager = userManager; + } + + /// <summary> + /// Gets legacy query filters. + /// </summary> + /// <param name="userId">Optional. User id.</param> + /// <param name="parentId">Optional. Parent id.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <response code="200">Legacy filters retrieved.</response> + /// <returns>Legacy query filters.</returns> + [HttpGet("Items/Filters")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( + [FromQuery] Guid? userId, + [FromQuery] string? parentId, + [FromQuery] string? includeItemTypes, + [FromQuery] string? mediaTypes) + { + var parentItem = string.IsNullOrEmpty(parentId) + ? null + : _libraryManager.GetItemById(parentId); + + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + { + parentItem = null; + } + + var item = string.IsNullOrEmpty(parentId) + ? user == null + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder() + : parentItem; + + var query = new InternalItemsQuery + { + User = user, + MediaTypes = (mediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + Recursive = true, + EnableTotalRecordCount = false, + DtoOptions = new DtoOptions + { + Fields = new[] { ItemFields.Genres, ItemFields.Tags }, + EnableImages = false, + EnableUserData = false + } + }; + + var itemList = ((Folder)item!).GetItemList(query); + return new QueryFiltersLegacy + { + Years = itemList.Select(i => i.ProductionYear ?? -1) + .Where(i => i > 0) + .Distinct() + .OrderBy(i => i) + .ToArray(), + + Genres = itemList.SelectMany(i => i.Genres) + .DistinctNames() + .OrderBy(i => i) + .ToArray(), + + Tags = itemList + .SelectMany(i => i.Tags) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(i => i) + .ToArray(), + + OfficialRatings = itemList + .Select(i => i.OfficialRating) + .Where(i => !string.IsNullOrWhiteSpace(i)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(i => i) + .ToArray() + }; + } + + /// <summary> + /// Gets query filters. + /// </summary> + /// <param name="userId">Optional. User id.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="isAiring">Optional. Is item airing.</param> + /// <param name="isMovie">Optional. Is item movie.</param> + /// <param name="isSports">Optional. Is item sports.</param> + /// <param name="isKids">Optional. Is item kids.</param> + /// <param name="isNews">Optional. Is item news.</param> + /// <param name="isSeries">Optional. Is item series.</param> + /// <param name="recursive">Optional. Search recursive.</param> + /// <response code="200">Filters retrieved.</response> + /// <returns>Query filters.</returns> + [HttpGet("Items/Filters2")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryFilters> GetQueryFilters( + [FromQuery] Guid? userId, + [FromQuery] string? parentId, + [FromQuery] string? includeItemTypes, + [FromQuery] bool? isAiring, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSports, + [FromQuery] bool? isKids, + [FromQuery] bool? isNews, + [FromQuery] bool? isSeries, + [FromQuery] bool? recursive) + { + var parentItem = string.IsNullOrEmpty(parentId) + ? null + : _libraryManager.GetItemById(parentId); + + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + { + parentItem = null; + } + + var filters = new QueryFilters(); + var genreQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = + (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + DtoOptions = new DtoOptions + { + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) + { + genreQuery.AncestorIds = parentItem == null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + } + else + { + genreQuery.Parent = parentItem; + } + + if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase)) + { + filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair + { + Name = i.Item1.Name, + Id = i.Item1.Id + }).ToArray(); + } + else + { + filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair + { + Name = i.Item1.Name, + Id = i.Item1.Id + }).ToArray(); + } + + return filters; + } + } +} diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs new file mode 100644 index 000000000..55ad71200 --- /dev/null +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -0,0 +1,320 @@ +using System; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Dto; +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; +using Genre = MediaBrowser.Controller.Entities.Genre; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The genres controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class GenresController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + + /// <summary> + /// Initializes a new instance of the <see cref="GenresController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public GenresController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService) + { + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + } + + /// <summary> + /// Gets all genres from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> + /// <response code="200">Genres returned.</response> + /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetGenres( + [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] string? 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] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions() + .AddItemFields(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) + { + 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 RequestHelpers.GetFilters(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<BaseItemDto> + { + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; + } + + /// <summary> + /// Gets a genre, by name. + /// </summary> + /// <param name="genreName">The genre name.</param> + /// <param name="userId">The user id.</param> + /// <response code="200">Genres returned.</response> + /// <returns>An <see cref="OkResult"/> containing the genre.</returns> + [HttpGet("{genreName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetGenre([FromRoute] string genreName, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions() + .AddClientFields(Request); + + Genre item = new Genre(); + if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) + { + var result = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions); + + if (result != null) + { + item = result; + } + } + else + { + item = _libraryManager.GetGenre(genreName); + } + + if (userId.HasValue && !userId.Equals(Guid.Empty)) + { + var user = _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + return _dtoService.GetBaseItemDto(item, dtoOptions); + } + + private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) + where T : BaseItem, new() + { + var result = libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '&'), + IncludeItemTypes = new[] { typeof(T).Name }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); + + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '/'), + IncludeItemTypes = new[] { typeof(T).Name }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); + + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '?'), + IncludeItemTypes = new[] { typeof(T).Name }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); + + return result; + } + } +} diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs new file mode 100644 index 000000000..e4a6842bc --- /dev/null +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -0,0 +1,154 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The hls segment controller. + /// </summary> + [Route("")] + public class HlsSegmentController : BaseJellyfinApiController + { + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + + /// <summary> + /// Initializes a new instance of the <see cref="HlsSegmentController"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="transcodingJobHelper">Initialized instance of the <see cref="TranscodingJobHelper"/>.</param> + public HlsSegmentController( + IFileSystem fileSystem, + IServerConfigurationManager serverConfigurationManager, + TranscodingJobHelper transcodingJobHelper) + { + _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; + _transcodingJobHelper = transcodingJobHelper; + } + + /// <summary> + /// Gets the specified audio segment for an audio item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="segmentId">The segment id.</param> + /// <response code="200">Hls audio segment returned.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns> + // Can't require authentication just yet due to seeing some requests come from Chrome without full query string + // [Authenticated] + [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")] + [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] + public ActionResult GetHlsAudioSegmentLegacy([FromRoute] string itemId, [FromRoute] string segmentId) + { + // TODO: Deprecate with new iOS app + var file = segmentId + Path.GetExtension(Request.Path); + file = Path.Combine(_serverConfigurationManager.GetTranscodePath(), file); + + return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)!, false, this); + } + + /// <summary> + /// Gets a hls video playlist. + /// </summary> + /// <param name="itemId">The video id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <response code="200">Hls video playlist returned.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns> + [HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] + public ActionResult GetHlsPlaylistLegacy([FromRoute] string itemId, [FromRoute] string playlistId) + { + var file = playlistId + Path.GetExtension(Request.Path); + file = Path.Combine(_serverConfigurationManager.GetTranscodePath(), file); + + return GetFileResult(file, file); + } + + /// <summary> + /// Stops an active encoding. + /// </summary> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="playSessionId">The play session id.</param> + /// <response code="204">Encoding stopped successfully.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpDelete("Videos/ActiveEncodings")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult StopEncodingProcess([FromQuery] string deviceId, [FromQuery] string playSessionId) + { + _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true); + return NoContent(); + } + + /// <summary> + /// Gets a hls video segment. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <param name="segmentId">The segment id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <response code="200">Hls video segment returned.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns> + // Can't require authentication just yet due to seeing some requests come from Chrome without full query string + // [Authenticated] + [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] + public ActionResult GetHlsVideoSegmentLegacy( + [FromRoute] string itemId, + [FromRoute] string playlistId, + [FromRoute] string segmentId, + [FromRoute] string segmentContainer) + { + var file = segmentId + Path.GetExtension(Request.Path); + var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); + + file = Path.Combine(transcodeFolderPath, file); + + var normalizedPlaylistId = playlistId; + + var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath) + .FirstOrDefault(i => + string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase) + && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1); + + return GetFileResult(file, playlistPath); + } + + private ActionResult GetFileResult(string path, string playlistPath) + { + var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); + + Response.OnCompleted(() => + { + if (transcodingJob != null) + { + _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); + } + + return Task.CompletedTask; + }); + + return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)!, false, this); + } + } +} diff --git a/Jellyfin.Api/Controllers/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs new file mode 100644 index 000000000..528590536 --- /dev/null +++ b/Jellyfin.Api/Controllers/ImageByNameController.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Net.Mime; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Images By Name Controller. + /// </summary> + [Route("Images")] + public class ImageByNameController : BaseJellyfinApiController + { + private readonly IServerApplicationPaths _applicationPaths; + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="ImageByNameController" /> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager" /> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem" /> interface.</param> + public ImageByNameController( + IServerConfigurationManager serverConfigurationManager, + IFileSystem fileSystem) + { + _applicationPaths = serverConfigurationManager.ApplicationPaths; + _fileSystem = fileSystem; + } + + /// <summary> + /// Get all general images. + /// </summary> + /// <response code="200">Retrieved list of images.</response> + /// <returns>An <see cref="OkResult"/> containing the list of images.</returns> + [HttpGet("General")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ImageByNameInfo>> GetGeneralImages() + { + return GetImageList(_applicationPaths.GeneralPath, false); + } + + /// <summary> + /// Get General Image. + /// </summary> + /// <param name="name">The name of the image.</param> + /// <param name="type">Image Type (primary, backdrop, logo, etc).</param> + /// <response code="200">Image stream retrieved.</response> + /// <response code="404">Image not found.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> + [HttpGet("General/{name}/{type}")] + [AllowAnonymous] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<FileStreamResult> GetGeneralImage([FromRoute, Required] string? name, [FromRoute, Required] string? type) + { + var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase) + ? "folder" + : type; + + var path = BaseItem.SupportedImageExtensions + .Select(i => Path.Combine(_applicationPaths.GeneralPath, name, filename + i)) + .FirstOrDefault(System.IO.File.Exists); + + if (path == null) + { + return NotFound(); + } + + var contentType = MimeTypes.GetMimeType(path); + return File(System.IO.File.OpenRead(path), contentType); + } + + /// <summary> + /// Get all general images. + /// </summary> + /// <response code="200">Retrieved list of images.</response> + /// <returns>An <see cref="OkResult"/> containing the list of images.</returns> + [HttpGet("Ratings")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ImageByNameInfo>> GetRatingImages() + { + return GetImageList(_applicationPaths.RatingsPath, false); + } + + /// <summary> + /// Get rating image. + /// </summary> + /// <param name="theme">The theme to get the image from.</param> + /// <param name="name">The name of the image.</param> + /// <response code="200">Image stream retrieved.</response> + /// <response code="404">Image not found.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> + [HttpGet("Ratings/{theme}/{name}")] + [AllowAnonymous] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<FileStreamResult> GetRatingImage( + [FromRoute, Required] string? theme, + [FromRoute, Required] string? name) + { + return GetImageFile(_applicationPaths.RatingsPath, theme, name); + } + + /// <summary> + /// Get all media info images. + /// </summary> + /// <response code="200">Image list retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the list of images.</returns> + [HttpGet("MediaInfo")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ImageByNameInfo>> GetMediaInfoImages() + { + return GetImageList(_applicationPaths.MediaInfoImagesPath, false); + } + + /// <summary> + /// Get media info image. + /// </summary> + /// <param name="theme">The theme to get the image from.</param> + /// <param name="name">The name of the image.</param> + /// <response code="200">Image stream retrieved.</response> + /// <response code="404">Image not found.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> + [HttpGet("MediaInfo/{theme}/{name}")] + [AllowAnonymous] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<FileStreamResult> GetMediaInfoImage( + [FromRoute, Required] string? theme, + [FromRoute, Required] string? name) + { + return GetImageFile(_applicationPaths.MediaInfoImagesPath, theme, name); + } + + /// <summary> + /// Internal FileHelper. + /// </summary> + /// <param name="basePath">Path to begin search.</param> + /// <param name="theme">Theme to search.</param> + /// <param name="name">File name to search for.</param> + /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> + private ActionResult<FileStreamResult> GetImageFile(string basePath, string? theme, string? name) + { + var themeFolder = Path.Combine(basePath, theme); + if (Directory.Exists(themeFolder)) + { + var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(themeFolder, name + i)) + .FirstOrDefault(System.IO.File.Exists); + + if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) + { + var contentType = MimeTypes.GetMimeType(path); + return File(System.IO.File.OpenRead(path), contentType); + } + } + + var allFolder = Path.Combine(basePath, "all"); + if (Directory.Exists(allFolder)) + { + var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(allFolder, name + i)) + .FirstOrDefault(System.IO.File.Exists); + + if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) + { + var contentType = MimeTypes.GetMimeType(path); + return File(System.IO.File.OpenRead(path), contentType); + } + } + + return NotFound(); + } + + private List<ImageByNameInfo> GetImageList(string path, bool supportsThemes) + { + try + { + return _fileSystem.GetFiles(path, BaseItem.SupportedImageExtensions, false, true) + .Select(i => new ImageByNameInfo + { + Name = _fileSystem.GetFileNameWithoutExtension(i), + FileLength = i.Length, + + // For themeable images, use the Theme property + // For general images, the same object structure is fine, + // but it's not owned by a theme, so call it Context + Theme = supportsThemes ? GetThemeName(i.FullName, path) : null, + Context = supportsThemes ? null : GetThemeName(i.FullName, path), + Format = i.Extension.ToLowerInvariant().TrimStart('.') + }) + .OrderBy(i => i.Name) + .ToList(); + } + catch (IOException) + { + return new List<ImageByNameInfo>(); + } + } + + private string? GetThemeName(string path, string rootImagePath) + { + var parentName = Path.GetDirectoryName(path); + + if (string.Equals(parentName, rootImagePath, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + parentName = Path.GetFileName(parentName); + + return string.Equals(parentName, "all", StringComparison.OrdinalIgnoreCase) ? null : parentName; + } + } +} diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs new file mode 100644 index 000000000..45447ae0c --- /dev/null +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -0,0 +1,1304 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Image controller. + /// </summary> + [Route("")] + public class ImageController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IImageProcessor _imageProcessor; + private readonly IFileSystem _fileSystem; + private readonly IAuthorizationContext _authContext; + private readonly ILogger<ImageController> _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ImageController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public ImageController( + IUserManager userManager, + ILibraryManager libraryManager, + IProviderManager providerManager, + IImageProcessor imageProcessor, + IFileSystem fileSystem, + IAuthorizationContext authContext, + ILogger<ImageController> logger, + IServerConfigurationManager serverConfigurationManager) + { + _userManager = userManager; + _libraryManager = libraryManager; + _providerManager = providerManager; + _imageProcessor = imageProcessor; + _fileSystem = fileSystem; + _authContext = authContext; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <summary> + /// Sets the user image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image updated.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/Images/{imageType}")] + [HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> PostUserImage( + [FromRoute] Guid userId, + [FromRoute] ImageType imageType, + [FromRoute] int? index = null) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return Forbid("User is not allowed to update the image."); + } + + var user = _userManager.GetUserById(userId); + await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType.Split(';').FirstOrDefault(); + var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + if (user.ProfileImage != null) + { + _userManager.ClearProfileImage(user); + } + + user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType))); + + await _providerManager + .SaveImage(user, memoryStream, mimeType, user.ProfileImage.Path) + .ConfigureAwait(false); + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Delete the user's image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Users/{userId}/Images/{itemType}")] + [HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult DeleteUserImage( + [FromRoute] Guid userId, + [FromRoute] ImageType imageType, + [FromRoute] int? index = null) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return Forbid("User is not allowed to delete the image."); + } + + var user = _userManager.GetUserById(userId); + try + { + System.IO.File.Delete(user.ProfileImage.Path); + } + catch (IOException e) + { + _logger.LogError(e, "Error deleting user profile image:"); + } + + _userManager.ClearProfileImage(user); + return NoContent(); + } + + /// <summary> + /// Delete an item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">The image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpDelete("Items/{itemId}/Images/{imageType}")] + [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteItemImage( + [FromRoute] Guid itemId, + [FromRoute] ImageType imageType, + [FromRoute] int? imageIndex = null) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + item.DeleteImage(imageType, imageIndex ?? 0); + return NoContent(); + } + + /// <summary> + /// Set item image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">(Unused) Image index.</param> + /// <response code="204">Image saved.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpPost("Items/{itemId}/Images/{imageType}")] + [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> SetItemImage( + [FromRoute] Guid itemId, + [FromRoute] ImageType imageType, + [FromRoute] int? imageIndex = null) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType.Split(';').FirstOrDefault(); + await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Updates the index for an item image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Old image index.</param> + /// <param name="newIndex">New image index.</param> + /// <response code="204">Image index updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateItemImageIndex( + [FromRoute] Guid itemId, + [FromRoute] ImageType imageType, + [FromRoute] int imageIndex, + [FromQuery] int newIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + item.SwapImages(imageType, imageIndex, newIndex); + return NoContent(); + } + + /// <summary> + /// Get item image infos. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item images returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The list of image infos on success, or <see cref="NotFoundResult"/> if item not found.</returns> + [HttpGet("Items/{itemId}/Images")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ImageInfo>> GetItemImageInfos([FromRoute] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + var list = new List<ImageInfo>(); + var itemImages = item.ImageInfos; + + if (itemImages.Length == 0) + { + // short-circuit + return list; + } + + _libraryManager.UpdateImages(item); // this makes sure dimensions and hashes are correct + + foreach (var image in itemImages) + { + if (!item.AllowsMultipleImages(image.Type)) + { + var info = GetImageInfo(item, image, null); + + if (info != null) + { + list.Add(info); + } + } + } + + foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages)) + { + var index = 0; + + // Prevent implicitly captured closure + var currentImageType = imageType; + + foreach (var image in itemImages.Where(i => i.Type == currentImageType)) + { + var info = GetImageInfo(item, image, index); + + if (info != null) + { + list.Add(info); + } + + index++; + } + } + + return list; + } + + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}")] + [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] + [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")] + [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetItemImage( + [FromRoute] Guid itemId, + [FromRoute] ImageType imageType, + [FromRoute] int? maxWidth, + [FromRoute] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] string? tag, + [FromQuery] bool? cropWhitespace, + [FromQuery] string? format, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute] int? imageIndex = null) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")] + [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetItemImage2( + [FromRoute] Guid itemId, + [FromRoute] ImageType imageType, + [FromRoute] int? maxWidth, + [FromRoute] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromRoute] string tag, + [FromQuery] bool? cropWhitespace, + [FromRoute] string format, + [FromQuery] bool? addPlayedIndicator, + [FromRoute] double? percentPlayed, + [FromRoute] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute] int? imageIndex = null) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Get artist image by name. + /// </summary> + /// <param name="name">Artist name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex?}")] + [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetArtistImage( + [FromRoute] string name, + [FromRoute] ImageType imageType, + [FromRoute] string tag, + [FromRoute] string format, + [FromRoute] int? maxWidth, + [FromRoute] int? maxHeight, + [FromRoute] double? percentPlayed, + [FromRoute] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute] int? imageIndex = null) + { + var item = _libraryManager.GetArtist(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Get genre image by name. + /// </summary> + /// <param name="name">Genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex?}")] + [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetGenreImage( + [FromRoute] string name, + [FromRoute] ImageType imageType, + [FromRoute] string tag, + [FromRoute] string format, + [FromRoute] int? maxWidth, + [FromRoute] int? maxHeight, + [FromRoute] double? percentPlayed, + [FromRoute] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute] int? imageIndex = null) + { + var item = _libraryManager.GetGenre(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Get music genre image by name. + /// </summary> + /// <param name="name">Music genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex?}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetMusicGenreImage( + [FromRoute] string name, + [FromRoute] ImageType imageType, + [FromRoute] string tag, + [FromRoute] string format, + [FromRoute] int? maxWidth, + [FromRoute] int? maxHeight, + [FromRoute] double? percentPlayed, + [FromRoute] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute] int? imageIndex = null) + { + var item = _libraryManager.GetMusicGenre(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Get person image by name. + /// </summary> + /// <param name="name">Person name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex?}")] + [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetPersonImage( + [FromRoute] string name, + [FromRoute] ImageType imageType, + [FromRoute] string tag, + [FromRoute] string format, + [FromRoute] int? maxWidth, + [FromRoute] int? maxHeight, + [FromRoute] double? percentPlayed, + [FromRoute] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute] int? imageIndex = null) + { + var item = _libraryManager.GetPerson(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Get studio image by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex?}")] + [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetStudioImage( + [FromRoute] string name, + [FromRoute] ImageType imageType, + [FromRoute] string tag, + [FromRoute] string format, + [FromRoute] int? maxWidth, + [FromRoute] int? maxHeight, + [FromRoute] double? percentPlayed, + [FromRoute] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute] int? imageIndex = null) + { + var item = _libraryManager.GetStudio(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Get user profile image. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex?}")] + [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetUserImage( + [FromRoute] Guid userId, + [FromRoute] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] string? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute] int? imageIndex = null) + { + var user = _userManager.GetUserById(userId); + if (user == null) + { + return NotFound(); + } + + var info = new ItemImageInfo + { + Path = user.ProfileImage.Path, + Type = ImageType.Profile, + DateModified = user.ProfileImage.LastModified + }; + + if (width.HasValue) + { + info.Width = width.Value; + } + + if (height.HasValue) + { + info.Height = height.Value; + } + + return await GetImageInternal( + user.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + null, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase), + info) + .ConfigureAwait(false); + } + + private static async Task<MemoryStream> GetMemoryStream(Stream inputStream) + { + using var reader = new StreamReader(inputStream); + var text = await reader.ReadToEndAsync().ConfigureAwait(false); + + var bytes = Convert.FromBase64String(text); + return new MemoryStream(bytes) { Position = 0 }; + } + + private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex) + { + int? width = null; + int? height = null; + string? blurhash = null; + long length = 0; + + try + { + if (info.IsLocalFile) + { + var fileInfo = _fileSystem.GetFileInfo(info.Path); + length = fileInfo.Length; + + blurhash = info.BlurHash; + width = info.Width; + height = info.Height; + + if (width <= 0 || height <= 0) + { + width = null; + height = null; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image information for {Item}", item.Name); + } + + try + { + return new ImageInfo + { + Path = info.Path, + ImageIndex = imageIndex, + ImageType = info.Type, + ImageTag = _imageProcessor.GetImageCacheTag(item, info), + Size = length, + BlurHash = blurhash, + Width = width, + Height = height + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image information for {Path}", info.Path); + return null; + } + } + + private async Task<ActionResult> GetImageInternal( + Guid itemId, + ImageType imageType, + int? imageIndex, + string? tag, + string? format, + int? maxWidth, + int? maxHeight, + double? percentPlayed, + int? unplayedCount, + int? width, + int? height, + int? quality, + bool? cropWhitespace, + bool? addPlayedIndicator, + int? blur, + string? backgroundColor, + string? foregroundLayer, + BaseItem? item, + bool isHeadRequest, + ItemImageInfo? imageInfo = null) + { + if (percentPlayed.HasValue) + { + if (percentPlayed.Value <= 0) + { + percentPlayed = null; + } + else if (percentPlayed.Value >= 100) + { + percentPlayed = null; + addPlayedIndicator = true; + } + } + + if (percentPlayed.HasValue) + { + unplayedCount = null; + } + + if (unplayedCount.HasValue + && unplayedCount.Value <= 0) + { + unplayedCount = null; + } + + if (imageInfo == null) + { + imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0); + if (imageInfo == null) + { + return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType)); + } + } + + cropWhitespace ??= imageType == ImageType.Logo || imageType == ImageType.Art; + + var outputFormats = GetOutputFormats(format); + + TimeSpan? cacheDuration = null; + + if (!string.IsNullOrEmpty(tag)) + { + cacheDuration = TimeSpan.FromDays(365); + } + + var responseHeaders = new Dictionary<string, string> + { + { "transferMode.dlna.org", "Interactive" }, + { "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" } + }; + + return await GetImageResult( + item, + itemId, + imageIndex, + height, + maxHeight, + maxWidth, + quality, + width, + addPlayedIndicator, + percentPlayed, + unplayedCount, + blur, + backgroundColor, + foregroundLayer, + imageInfo, + cropWhitespace.Value, + outputFormats, + cacheDuration, + responseHeaders, + isHeadRequest).ConfigureAwait(false); + } + + private ImageFormat[] GetOutputFormats(string? format) + { + if (!string.IsNullOrWhiteSpace(format) + && Enum.TryParse(format, true, out ImageFormat parsedFormat)) + { + return new[] { parsedFormat }; + } + + return GetClientSupportedFormats(); + } + + private ImageFormat[] GetClientSupportedFormats() + { + var acceptTypes = Request.Headers[HeaderNames.Accept]; + var supportedFormats = new List<string>(); + if (acceptTypes.Count > 0) + { + foreach (var type in acceptTypes) + { + int index = type.IndexOf(';', StringComparison.Ordinal); + if (index != -1) + { + supportedFormats.Add(type.Substring(0, index)); + } + } + } + + var acceptParam = Request.Query[HeaderNames.Accept]; + + var supportsWebP = SupportsFormat(supportedFormats, acceptParam, "webp", false); + + if (!supportsWebP) + { + var userAgent = Request.Headers[HeaderNames.UserAgent].ToString(); + if (userAgent.IndexOf("crosswalk", StringComparison.OrdinalIgnoreCase) != -1 && + userAgent.IndexOf("android", StringComparison.OrdinalIgnoreCase) != -1) + { + supportsWebP = true; + } + } + + var formats = new List<ImageFormat>(4); + + if (supportsWebP) + { + formats.Add(ImageFormat.Webp); + } + + formats.Add(ImageFormat.Jpg); + formats.Add(ImageFormat.Png); + + if (SupportsFormat(supportedFormats, acceptParam, "gif", true)) + { + formats.Add(ImageFormat.Gif); + } + + return formats.ToArray(); + } + + private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string acceptParam, string format, bool acceptAll) + { + var mimeType = "image/" + format; + + if (requestAcceptTypes.Contains(mimeType)) + { + return true; + } + + if (acceptAll && requestAcceptTypes.Contains("*/*")) + { + return true; + } + + return string.Equals(acceptParam, format, StringComparison.OrdinalIgnoreCase); + } + + private async Task<ActionResult> GetImageResult( + BaseItem? item, + Guid itemId, + int? index, + int? height, + int? maxHeight, + int? maxWidth, + int? quality, + int? width, + bool? addPlayedIndicator, + double? percentPlayed, + int? unplayedCount, + int? blur, + string? backgroundColor, + string? foregroundLayer, + ItemImageInfo imageInfo, + bool cropWhitespace, + IReadOnlyCollection<ImageFormat> supportedFormats, + TimeSpan? cacheDuration, + IDictionary<string, string> headers, + bool isHeadRequest) + { + if (!imageInfo.IsLocalFile && item != null) + { + imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, index ?? 0).ConfigureAwait(false); + } + + var options = new ImageProcessingOptions + { + CropWhiteSpace = cropWhitespace, + Height = height, + ImageIndex = index ?? 0, + Image = imageInfo, + Item = item, + ItemId = itemId, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + Quality = quality ?? 100, + Width = width, + AddPlayedIndicator = addPlayedIndicator ?? false, + PercentPlayed = percentPlayed ?? 0, + UnplayedCount = unplayedCount, + Blur = blur, + BackgroundColor = backgroundColor, + ForegroundLayer = foregroundLayer, + SupportedOutputFormats = supportedFormats + }; + + var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(options).ConfigureAwait(false); + + var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache"); + var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader); + + // if the parsing of the IfModifiedSince header was not successful, disable caching + if (!parsingSuccessful) + { + // disableCaching = true; + } + + foreach (var (key, value) in headers) + { + Response.Headers.Add(key, value); + } + + Response.ContentType = imageContentType; + Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture)); + Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept); + + if (disableCaching) + { + Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate"); + Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate"); + } + else + { + if (cacheDuration.HasValue) + { + Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds); + } + else + { + Response.Headers.Add(HeaderNames.CacheControl, "public"); + } + + Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", new CultureInfo("en-US", false))); + + // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified + if (!(dateImageModified > ifModifiedSinceHeader)) + { + if (ifModifiedSinceHeader.Add(cacheDuration!.Value) < DateTime.UtcNow) + { + Response.StatusCode = StatusCodes.Status304NotModified; + return new ContentResult(); + } + } + } + + // if the request is a head request, return a NoContent result with the same headers as it would with a GET request + if (isHeadRequest) + { + return NoContent(); + } + + var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read); + return File(stream, imageContentType); + } + } +} diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs new file mode 100644 index 000000000..73bd30c4d --- /dev/null +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +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.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The instant mix controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class InstantMixController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly ILibraryManager _libraryManager; + private readonly IMusicManager _musicManager; + + /// <summary> + /// Initializes a new instance of the <see cref="InstantMixController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public InstantMixController( + IUserManager userManager, + IDtoService dtoService, + IMusicManager musicManager, + ILibraryManager libraryManager) + { + _userManager = userManager; + _dtoService = dtoService; + _musicManager = musicManager; + _libraryManager = libraryManager; + } + + /// <summary> + /// Creates an instant playlist based on a given song. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Songs/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong( + [FromRoute] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] string? fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? 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) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } + + /// <summary> + /// Creates an instant playlist based on a given song. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Albums/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum( + [FromRoute] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] string? fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? 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) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } + + /// <summary> + /// Creates an instant playlist based on a given song. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Playlists/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist( + [FromRoute] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] string? fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? 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) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } + + /// <summary> + /// Creates an instant playlist based on a given song. + /// </summary> + /// <param name="name">The genre name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("MusicGenres/{name}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenre( + [FromRoute, Required] string? name, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] string? fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } + + /// <summary> + /// Creates an instant playlist based on a given song. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Artists/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( + [FromRoute] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] string? fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? 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) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } + + /// <summary> + /// Creates an instant playlist based on a given song. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("MusicGenres/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres( + [FromRoute] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] string? fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? 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) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } + + /// <summary> + /// Creates an instant playlist based on a given song. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Items/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( + [FromRoute] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] string? fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? 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) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } + + private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions) + { + var list = items; + + var result = new QueryResult<BaseItemDto> + { + TotalRecordCount = list.Count + }; + + if (limit.HasValue) + { + list = list.Take(limit.Value).ToList(); + } + + var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); + + result.Items = returnList; + + return result; + } + } +} diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs new file mode 100644 index 000000000..c9ad15bab --- /dev/null +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Item lookup controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ItemLookupController : BaseJellyfinApiController + { + private readonly IProviderManager _providerManager; + private readonly IServerApplicationPaths _appPaths; + private readonly IFileSystem _fileSystem; + private readonly ILibraryManager _libraryManager; + private readonly ILogger<ItemLookupController> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemLookupController"/> class. + /// </summary> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param> + public ItemLookupController( + IProviderManager providerManager, + IServerConfigurationManager serverConfigurationManager, + IFileSystem fileSystem, + ILibraryManager libraryManager, + ILogger<ItemLookupController> logger) + { + _providerManager = providerManager; + _appPaths = serverConfigurationManager.ApplicationPaths; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _logger = logger; + } + + /// <summary> + /// Get the item's external id info. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <response code="200">External id info retrieved.</response> + /// <response code="404">Item not found.</response> + /// <returns>List of external id info.</returns> + [HttpGet("Items/{itemId}/ExternalIdInfos")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + return Ok(_providerManager.GetExternalIdInfos(item)); + } + + /// <summary> + /// Get movie remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Movie remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Movie")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get trailer remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Trailer remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Trailer")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get music video remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music video remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/MusicVideo")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get series remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Series remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Series")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get box set remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Box set remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/BoxSet")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get music artist remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music artist remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/MusicArtist")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get music album remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music album remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/MusicAlbum")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get person remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Person remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Person")] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get book remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Book remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Book")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Gets a remote image. + /// </summary> + /// <param name="imageUrl">The image url.</param> + /// <param name="providerName">The provider name.</param> + /// <response code="200">Remote image retrieved.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="FileStreamResult"/> containing the images file stream. + /// </returns> + [HttpGet("Items/RemoteSearch/Image")] + public async Task<ActionResult> GetRemoteSearchImage( + [FromQuery, Required] string imageUrl, + [FromQuery, Required] string providerName) + { + var urlHash = imageUrl.GetMD5(); + var pointerCachePath = GetFullCachePath(urlHash.ToString()); + + try + { + var contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); + if (System.IO.File.Exists(contentPath)) + { + await using var fileStreamExisting = System.IO.File.OpenRead(pointerCachePath); + return new FileStreamResult(fileStreamExisting, MediaTypeNames.Application.Octet); + } + } + catch (FileNotFoundException) + { + // Means the file isn't cached yet + } + catch (IOException) + { + // Means the file isn't cached yet + } + + await DownloadImage(providerName, imageUrl, urlHash, pointerCachePath).ConfigureAwait(false); + + // Read the pointer file again + await using var fileStream = System.IO.File.OpenRead(pointerCachePath); + return new FileStreamResult(fileStream, MediaTypeNames.Application.Octet); + } + + /// <summary> + /// Applies search criteria to an item and refreshes metadata. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="searchResult">The remote search result.</param> + /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param> + /// <response code="204">Item metadata refreshed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="NoContentResult"/>. + /// </returns> + [HttpPost("Items/RemoteSearch/Apply/{id}")] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult> ApplySearchCriteria( + [FromRoute] Guid itemId, + [FromBody, Required] RemoteSearchResult searchResult, + [FromQuery] bool replaceAllImages = true) + { + var item = _libraryManager.GetItemById(itemId); + _logger.LogInformation( + "Setting provider id's to item {0}-{1}: {2}", + item.Id, + item.Name, + JsonSerializer.Serialize(searchResult.ProviderIds)); + + // Since the refresh process won't erase provider Ids, we need to set this explicitly now. + item.ProviderIds = searchResult.ProviderIds; + await _providerManager.RefreshFullItem( + item, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = replaceAllImages, + SearchResult = searchResult + }, CancellationToken.None).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Downloads the image. + /// </summary> + /// <param name="providerName">Name of the provider.</param> + /// <param name="url">The URL.</param> + /// <param name="urlHash">The URL hash.</param> + /// <param name="pointerCachePath">The pointer cache path.</param> + /// <returns>Task.</returns> + private async Task DownloadImage(string providerName, string url, Guid urlHash, string pointerCachePath) + { + var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false); + var ext = result.ContentType.Split('/').Last(); + var fullCachePath = GetFullCachePath(urlHash + "." + ext); + + Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); + await using (var stream = result.Content) + { + await using var fileStream = new FileStream( + fullCachePath, + FileMode.Create, + FileAccess.Write, + FileShare.Read, + IODefaults.FileStreamBufferSize, + true); + + await stream.CopyToAsync(fileStream).ConfigureAwait(false); + } + + Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); + await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath).ConfigureAwait(false); + } + + /// <summary> + /// Gets the full cache path. + /// </summary> + /// <param name="filename">The filename.</param> + /// <returns>System.String.</returns> + private string GetFullCachePath(string filename) + => Path.Combine(_appPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); + } +} diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs new file mode 100644 index 000000000..3f5d305c1 --- /dev/null +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -0,0 +1,85 @@ +using System; +using System.ComponentModel; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Item Refresh Controller. + /// </summary> + [Route("Items")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ItemRefreshController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemRefreshController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + public ItemRefreshController( + ILibraryManager libraryManager, + IProviderManager providerManager, + IFileSystem fileSystem) + { + _libraryManager = libraryManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + } + + /// <summary> + /// Refreshes metadata for an item. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param> + /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param> + /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param> + /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param> + /// <response code="204">Item metadata refresh queued.</response> + /// <response code="404">Item to refresh not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("{itemId}/Refresh")] + [Description("Refreshes metadata for an item.")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult Post( + [FromRoute] Guid itemId, + [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, + [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None, + [FromQuery] bool replaceAllMetadata = false, + [FromQuery] bool replaceAllImages = false) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = metadataRefreshMode, + ImageRefreshMode = imageRefreshMode, + ReplaceAllImages = replaceAllImages, + ReplaceAllMetadata = replaceAllMetadata, + ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh + || imageRefreshMode == MetadataRefreshMode.FullRefresh + || replaceAllImages + || replaceAllMetadata, + IsAutomated = false + }; + + _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); + return NoContent(); + } + } +} diff --git a/MediaBrowser.Api/ItemUpdateService.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 2db6d717a..4b40c6ada 100644 --- a/MediaBrowser.Api/ItemUpdateService.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -1,215 +1,100 @@ -using System; +using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; +using Jellyfin.Api.Constants; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; -namespace MediaBrowser.Api +namespace Jellyfin.Api.Controllers { - [Route("/Items/{ItemId}", "POST", Summary = "Updates an item")] - public class UpdateItem : BaseItemDto, IReturnVoid - { - [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string ItemId { get; set; } - } - - [Route("/Items/{ItemId}/MetadataEditor", "GET", Summary = "Gets metadata editor info for an item")] - public class GetMetadataEditorInfo : IReturn<MetadataEditorInfo> - { - [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string ItemId { get; set; } - } - - [Route("/Items/{ItemId}/ContentType", "POST", Summary = "Updates an item's content type")] - public class UpdateItemContentType : IReturnVoid - { - [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid ItemId { get; set; } - - [ApiMember(Name = "ContentType", Description = "The content type of the item", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ContentType { get; set; } - } - - [Authenticated(Roles = "admin")] - public class ItemUpdateService : BaseApiService + /// <summary> + /// Item update controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.RequiresElevation)] + public class ItemUpdateController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; private readonly IProviderManager _providerManager; private readonly ILocalizationManager _localizationManager; private readonly IFileSystem _fileSystem; - - public ItemUpdateService( - ILogger<ItemUpdateService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemUpdateController"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public ItemUpdateController( IFileSystem fileSystem, ILibraryManager libraryManager, IProviderManager providerManager, - ILocalizationManager localizationManager) - : base(logger, serverConfigurationManager, httpResultFactory) + ILocalizationManager localizationManager, + IServerConfigurationManager serverConfigurationManager) { _libraryManager = libraryManager; _providerManager = providerManager; _localizationManager = localizationManager; _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; } - public object Get(GetMetadataEditorInfo request) + /// <summary> + /// Updates an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="request">The new item properties.</param> + /// <response code="204">Item updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("Items/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateItem([FromRoute] Guid itemId, [FromBody, Required] BaseItemDto request) { - var item = _libraryManager.GetItemById(request.ItemId); - - var info = new MetadataEditorInfo - { - ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(), - ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), - Countries = _localizationManager.GetCountries().ToArray(), - Cultures = _localizationManager.GetCultures().ToArray() - }; - - if (!item.IsVirtualItem && !(item is ICollectionFolder) && !(item is UserView) && !(item is AggregateFolder) && !(item is LiveTvChannel) && !(item is IItemByName) && - item.SourceType == SourceType.Library) - { - var inheritedContentType = _libraryManager.GetInheritedContentType(item); - var configuredContentType = _libraryManager.GetConfiguredContentType(item); - - if (string.IsNullOrWhiteSpace(inheritedContentType) || !string.IsNullOrWhiteSpace(configuredContentType)) - { - info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); - info.ContentType = configuredContentType; - - if (string.IsNullOrWhiteSpace(inheritedContentType) || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) - { - info.ContentTypeOptions = info.ContentTypeOptions - .Where(i => string.IsNullOrWhiteSpace(i.Value) || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - } - } - - return ToOptimizedResult(info); - } - - public void Post(UpdateItemContentType request) - { - var item = _libraryManager.GetItemById(request.ItemId); - var path = item.ContainingFolderPath; - - var types = ServerConfigurationManager.Configuration.ContentTypes - .Where(i => !string.IsNullOrWhiteSpace(i.Name)) - .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - if (!string.IsNullOrWhiteSpace(request.ContentType)) - { - types.Add(new NameValuePair - { - Name = path, - Value = request.ContentType - }); - } - - ServerConfigurationManager.Configuration.ContentTypes = types.ToArray(); - ServerConfigurationManager.SaveConfiguration(); - } - - private List<NameValuePair> GetContentTypeOptions(bool isForItem) - { - var list = new List<NameValuePair>(); - - if (isForItem) - { - list.Add(new NameValuePair - { - Name = "Inherit", - Value = "" - }); - } - - list.Add(new NameValuePair - { - Name = "Movies", - Value = "movies" - }); - list.Add(new NameValuePair - { - Name = "Music", - Value = "music" - }); - list.Add(new NameValuePair - { - Name = "Shows", - Value = "tvshows" - }); - - if (!isForItem) + var item = _libraryManager.GetItemById(itemId); + if (item == null) { - list.Add(new NameValuePair - { - Name = "Books", - Value = "books" - }); + return NotFound(); } - list.Add(new NameValuePair - { - Name = "HomeVideos", - Value = "homevideos" - }); - list.Add(new NameValuePair - { - Name = "MusicVideos", - Value = "musicvideos" - }); - list.Add(new NameValuePair - { - Name = "Photos", - Value = "photos" - }); - - if (!isForItem) - { - list.Add(new NameValuePair - { - Name = "MixedContent", - Value = "" - }); - } - - foreach (var val in list) - { - val.Name = _localizationManager.GetLocalizedString(val.Name); - } - - return list; - } - - public void Post(UpdateItem request) - { - var item = _libraryManager.GetItemById(request.ItemId); - var newLockData = request.LockData ?? false; var isLockedChanged = item.IsLocked != newLockData; var series = item as Series; - var displayOrderChanged = series != null && !string.Equals(series.DisplayOrder ?? string.Empty, request.DisplayOrder ?? string.Empty, StringComparison.OrdinalIgnoreCase); + var displayOrderChanged = series != null && !string.Equals( + series.DisplayOrder ?? string.Empty, + request.DisplayOrder ?? string.Empty, + StringComparison.OrdinalIgnoreCase); // Do this first so that metadata savers can pull the updates from the database. if (request.People != null) { - _libraryManager.UpdatePeople(item, request.People.Select(x => new PersonInfo { Name = x.Name, Role = x.Role, Type = x.Type }).ToList()); + _libraryManager.UpdatePeople( + item, + request.People.Select(x => new PersonInfo + { + Name = x.Name, + Role = x.Role, + Type = x.Type + }).ToList()); } UpdateItem(request, item); @@ -232,7 +117,7 @@ namespace MediaBrowser.Api if (displayOrderChanged) { _providerManager.QueueRefresh( - series.Id, + series!.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, @@ -241,11 +126,101 @@ namespace MediaBrowser.Api }, RefreshPriority.High); } + + return NoContent(); } - private DateTime NormalizeDateTime(DateTime val) + /// <summary> + /// Gets metadata editor info for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">Item metadata editor returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpGet("Items/{itemId}/MetadataEditor")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute] Guid itemId) { - return DateTime.SpecifyKind(val, DateTimeKind.Utc); + var item = _libraryManager.GetItemById(itemId); + + var info = new MetadataEditorInfo + { + ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(), + ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), + Countries = _localizationManager.GetCountries().ToArray(), + Cultures = _localizationManager.GetCultures().ToArray() + }; + + if (!item.IsVirtualItem + && !(item is ICollectionFolder) + && !(item is UserView) + && !(item is AggregateFolder) + && !(item is LiveTvChannel) + && !(item is IItemByName) + && item.SourceType == SourceType.Library) + { + var inheritedContentType = _libraryManager.GetInheritedContentType(item); + var configuredContentType = _libraryManager.GetConfiguredContentType(item); + + if (string.IsNullOrWhiteSpace(inheritedContentType) || + !string.IsNullOrWhiteSpace(configuredContentType)) + { + info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); + info.ContentType = configuredContentType; + + if (string.IsNullOrWhiteSpace(inheritedContentType) + || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) + { + info.ContentTypeOptions = info.ContentTypeOptions + .Where(i => string.IsNullOrWhiteSpace(i.Value) + || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + } + } + + return info; + } + + /// <summary> + /// Updates an item's content type. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="contentType">The content type of the item.</param> + /// <response code="204">Item content type updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("Items/{itemId}/ContentType")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, Required] string? contentType) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + var path = item.ContainingFolderPath; + + var types = _serverConfigurationManager.Configuration.ContentTypes + .Where(i => !string.IsNullOrWhiteSpace(i.Name)) + .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (!string.IsNullOrWhiteSpace(contentType)) + { + types.Add(new NameValuePair + { + Name = path, + Value = contentType + }); + } + + _serverConfigurationManager.Configuration.ContentTypes = types.ToArray(); + _serverConfigurationManager.SaveConfiguration(); + return NoContent(); } private void UpdateItem(BaseItemDto request, BaseItem item) @@ -361,24 +336,25 @@ namespace MediaBrowser.Api } } - if (item is Audio song) - { - song.Album = request.Album; - } - - if (item is MusicVideo musicVideo) + switch (item) { - musicVideo.Album = request.Album; - } + case Audio song: + song.Album = request.Album; + break; + case MusicVideo musicVideo: + musicVideo.Album = request.Album; + break; + case Series series: + { + series.Status = GetSeriesStatus(request); - if (item is Series series) - { - series.Status = GetSeriesStatus(request); + if (request.AirDays != null) + { + series.AirDays = request.AirDays; + series.AirTime = request.AirTime; + } - if (request.AirDays != null) - { - series.AirDays = request.AirDays; - series.AirTime = request.AirTime; + break; } } } @@ -392,5 +368,81 @@ namespace MediaBrowser.Api return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true); } + + private DateTime NormalizeDateTime(DateTime val) + { + return DateTime.SpecifyKind(val, DateTimeKind.Utc); + } + + private List<NameValuePair> GetContentTypeOptions(bool isForItem) + { + var list = new List<NameValuePair>(); + + if (isForItem) + { + list.Add(new NameValuePair + { + Name = "Inherit", + Value = string.Empty + }); + } + + list.Add(new NameValuePair + { + Name = "Movies", + Value = "movies" + }); + list.Add(new NameValuePair + { + Name = "Music", + Value = "music" + }); + list.Add(new NameValuePair + { + Name = "Shows", + Value = "tvshows" + }); + + if (!isForItem) + { + list.Add(new NameValuePair + { + Name = "Books", + Value = "books" + }); + } + + list.Add(new NameValuePair + { + Name = "HomeVideos", + Value = "homevideos" + }); + list.Add(new NameValuePair + { + Name = "MusicVideos", + Value = "musicvideos" + }); + list.Add(new NameValuePair + { + Name = "Photos", + Value = "photos" + }); + + if (!isForItem) + { + list.Add(new NameValuePair + { + Name = "MixedContent", + Value = string.Empty + }); + } + + foreach (var val in list) + { + val.Name = _localizationManager.GetLocalizedString(val.Name); + } + + return list; + } } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs new file mode 100644 index 000000000..1b8b68313 --- /dev/null +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -0,0 +1,593 @@ +using System; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The items controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ItemsController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly ILocalizationManager _localization; + private readonly IDtoService _dtoService; + private readonly ILogger<ItemsController> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemsController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + public ItemsController( + IUserManager userManager, + ILibraryManager libraryManager, + ILocalizationManager localization, + IDtoService dtoService, + ILogger<ItemsController> logger) + { + _userManager = userManager; + _libraryManager = libraryManager; + _localization = localization; + _dtoService = dtoService; + _logger = logger; + } + + /// <summary> + /// Gets items based on a query. + /// </summary> + /// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param> + /// <param name="userId">The user id supplied as query parameter.</param> + /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> + /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> + /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> + /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> + /// <param name="hasTrailer">Optional filter by items with trailers.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="parentIndexNumber">Optional filter by parent index number.</param> + /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> + /// <param name="isHd">Optional filter by items that are HD or not.</param> + /// <param name="is4K">Optional filter by items that are 4K or not.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param> + /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> + /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> + /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> + /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> + /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> + /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> + /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> + /// <param name="searchTerm">Optional. Filter based on a search term.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="isPlayed">Optional filter by items that are played, or not.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param> + /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> + /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> + /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param> + /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param> + /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="isLocked">Optional filter by items that are locked.</param> + /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> + /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> + /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> + /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> + /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> + /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> + /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> + /// <param name="is3D">Optional filter by items that are 3D, or not.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param> + /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> + [HttpGet("Items")] + [HttpGet("Users/{uId}/Items", Name = "GetItems_2")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetItems( + [FromRoute] Guid? uId, + [FromQuery] Guid? userId, + [FromQuery] string? maxOfficialRating, + [FromQuery] bool? hasThemeSong, + [FromQuery] bool? hasThemeVideo, + [FromQuery] bool? hasSubtitles, + [FromQuery] bool? hasSpecialFeature, + [FromQuery] bool? hasTrailer, + [FromQuery] string? adjacentTo, + [FromQuery] int? parentIndexNumber, + [FromQuery] bool? hasParentalRating, + [FromQuery] bool? isHd, + [FromQuery] bool? is4K, + [FromQuery] string? locationTypes, + [FromQuery] string? excludeLocationTypes, + [FromQuery] bool? isMissing, + [FromQuery] bool? isUnaired, + [FromQuery] double? minCommunityRating, + [FromQuery] double? minCriticRating, + [FromQuery] DateTime? minPremiereDate, + [FromQuery] DateTime? minDateLastSaved, + [FromQuery] DateTime? minDateLastSavedForUser, + [FromQuery] DateTime? maxPremiereDate, + [FromQuery] bool? hasOverview, + [FromQuery] bool? hasImdbId, + [FromQuery] bool? hasTmdbId, + [FromQuery] bool? hasTvdbId, + [FromQuery] string? excludeItemIds, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? recursive, + [FromQuery] string? searchTerm, + [FromQuery] string? sortOrder, + [FromQuery] string? parentId, + [FromQuery] string? fields, + [FromQuery] string? excludeItemTypes, + [FromQuery] string? includeItemTypes, + [FromQuery] string? filters, + [FromQuery] bool? isFavorite, + [FromQuery] string? mediaTypes, + [FromQuery] string? imageTypes, + [FromQuery] string? sortBy, + [FromQuery] bool? isPlayed, + [FromQuery] string? genres, + [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? artists, + [FromQuery] string? excludeArtistIds, + [FromQuery] string? artistIds, + [FromQuery] string? albumArtistIds, + [FromQuery] string? contributingArtistIds, + [FromQuery] string? albums, + [FromQuery] string? albumIds, + [FromQuery] string? ids, + [FromQuery] string? videoTypes, + [FromQuery] string? minOfficialRating, + [FromQuery] bool? isLocked, + [FromQuery] bool? isPlaceHolder, + [FromQuery] bool? hasOfficialRating, + [FromQuery] bool? collapseBoxSetItems, + [FromQuery] int? minWidth, + [FromQuery] int? minHeight, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? is3D, + [FromQuery] string? seriesStatus, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] string? studioIds, + [FromQuery] string? genreIds, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true) + { + // use user id route parameter over query parameter + userId = uId ?? userId; + + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase)) + { + parentId = null; + } + + BaseItem? item = null; + QueryResult<BaseItem> result; + if (!string.IsNullOrEmpty(parentId)) + { + item = _libraryManager.GetItemById(parentId); + } + + item ??= _libraryManager.GetUserRootFolder(); + + if (!(item is Folder folder)) + { + folder = _libraryManager.GetUserRootFolder(); + } + + if (folder is IHasCollectionType hasCollectionType + && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) + { + recursive = true; + includeItemTypes = "Playlist"; + } + + bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id) + // Assume all folders inside an EnabledChannel are enabled + || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id); + + var collectionFolders = _libraryManager.GetCollectionFolders(item); + foreach (var collectionFolder in collectionFolders) + { + if (user.GetPreference(PreferenceKind.EnabledFolders).Contains( + collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture), + StringComparer.OrdinalIgnoreCase)) + { + isInEnabledFolder = true; + } + } + + if (!(item is UserRootFolder) + && !isInEnabledFolder + && !user.HasPermission(PermissionKind.EnableAllFolders) + && !user.HasPermission(PermissionKind.EnableAllChannels)) + { + _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}.", user.Username, item.Name); + return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); + } + + if ((recursive.HasValue && recursive.Value) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder)) + { + var query = new InternalItemsQuery(user!) + { + IsPlayed = isPlayed, + MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + Recursive = recursive ?? false, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + IsFavorite = isFavorite, + Limit = limit, + StartIndex = startIndex, + IsMissing = isMissing, + IsUnaired = isUnaired, + CollapseBoxSetItems = collapseBoxSetItems, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + HasImdbId = hasImdbId, + IsPlaceHolder = isPlaceHolder, + IsLocked = isLocked, + MinWidth = minWidth, + MinHeight = minHeight, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + Is3D = is3D, + HasTvdbId = hasTvdbId, + HasTmdbId = hasTmdbId, + HasOverview = hasOverview, + HasOfficialRating = hasOfficialRating, + HasParentalRating = hasParentalRating, + HasSpecialFeature = hasSpecialFeature, + HasSubtitles = hasSubtitles, + HasThemeSong = hasThemeSong, + HasThemeVideo = hasThemeVideo, + HasTrailer = hasTrailer, + IsHD = isHd, + Is4K = is4K, + Tags = RequestHelpers.Split(tags, '|', true), + OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), + Genres = RequestHelpers.Split(genres, '|', true), + ArtistIds = RequestHelpers.GetGuids(artistIds), + AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds), + ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds), + 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(), + ImageTypes = RequestHelpers.Split(imageTypes, ',', true).Select(v => Enum.Parse<ImageType>(v, true)).ToArray(), + VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(), + AdjacentTo = adjacentTo, + ItemIds = RequestHelpers.GetGuids(ids), + MinCommunityRating = minCommunityRating, + MinCriticRating = minCriticRating, + ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId), + ParentIndexNumber = parentIndexNumber, + EnableTotalRecordCount = enableTotalRecordCount, + ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds), + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), + MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), + MinPremiereDate = minPremiereDate?.ToUniversalTime(), + MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + }; + + if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm)) + { + query.CollapseBoxSetItems = false; + } + + foreach (var filter in RequestHelpers.GetFilters(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; + } + } + + // Filter by Series Status + if (!string.IsNullOrEmpty(seriesStatus)) + { + query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray(); + } + + // ExcludeLocationTypes + if (!string.IsNullOrEmpty(excludeLocationTypes)) + { + if (excludeLocationTypes.Split(',').Select(d => (LocationType)Enum.Parse(typeof(LocationType), d, true)).ToArray().Contains(LocationType.Virtual)) + { + query.IsVirtualItem = false; + } + } + + if (!string.IsNullOrEmpty(locationTypes)) + { + var requestedLocationTypes = locationTypes.Split(','); + if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4) + { + query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString()); + } + } + + // Min official rating + if (!string.IsNullOrWhiteSpace(minOfficialRating)) + { + query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating); + } + + // Max official rating + if (!string.IsNullOrWhiteSpace(maxOfficialRating)) + { + query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating); + } + + // Artists + if (!string.IsNullOrEmpty(artists)) + { + query.ArtistIds = artists.Split('|').Select(i => + { + try + { + return _libraryManager.GetArtist(i, new DtoOptions(false)); + } + catch + { + return null; + } + }).Where(i => i != null).Select(i => i!.Id).ToArray(); + } + + // ExcludeArtistIds + if (!string.IsNullOrWhiteSpace(excludeArtistIds)) + { + query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); + } + + if (!string.IsNullOrWhiteSpace(albumIds)) + { + query.AlbumIds = RequestHelpers.GetGuids(albumIds); + } + + // Albums + if (!string.IsNullOrEmpty(albums)) + { + query.AlbumIds = albums.Split('|').SelectMany(i => + { + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 }); + }).ToArray(); + } + + // 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(); + } + + // Apply default sorting if none requested + if (query.OrderBy.Count == 0) + { + // Albums by artist + if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], "MusicAlbum", StringComparison.OrdinalIgnoreCase)) + { + query.OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.ProductionYear, SortOrder.Descending), new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) }; + } + } + + result = folder.GetItems(query); + } + else + { + var itemsArray = folder.GetChildren(user, true); + result = new QueryResult<BaseItem> { Items = itemsArray, TotalRecordCount = itemsArray.Count, StartIndex = 0 }; + } + + return new QueryResult<BaseItemDto> { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) }; + } + + /// <summary> + /// Gets items based on a query. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="startIndex">The start index.</param> + /// <param name="limit">The item limit.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param> + /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <response code="200">Items returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns> + [HttpGet("Users/{userId}/Items/Resume")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetResumeItems( + [FromRoute] Guid userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] string? parentId, + [FromQuery] string? fields, + [FromQuery] string? mediaTypes, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] string? excludeItemTypes, + [FromQuery] string? includeItemTypes, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true) + { + var user = _userManager.GetUserById(userId); + var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var ancestorIds = Array.Empty<Guid>(); + + var excludeFolderIds = user.GetPreference(PreferenceKind.LatestItemExcludes); + if (parentIdGuid.Equals(Guid.Empty) && excludeFolderIds.Length > 0) + { + ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) + .Where(i => i is Folder) + .Where(i => !excludeFolderIds.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))) + .Select(i => i.Id) + .ToArray(); + } + + var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) + { + OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, + IsResumable = true, + StartIndex = startIndex, + Limit = limit, + ParentId = parentIdGuid, + Recursive = true, + DtoOptions = dtoOptions, + MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + IsVirtualItem = false, + CollapseBoxSetItems = false, + EnableTotalRecordCount = enableTotalRecordCount, + AncestorIds = ancestorIds, + IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + SearchTerm = searchTerm + }); + + var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user); + + return new QueryResult<BaseItemDto> + { + StartIndex = startIndex.GetValueOrDefault(), + TotalRecordCount = itemsResult.TotalRecordCount, + Items = returnItems + }; + } + } +} diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs new file mode 100644 index 000000000..4548e202a --- /dev/null +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -0,0 +1,1036 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.LibraryDtos; +using Jellyfin.Data.Entities; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Activity; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Book = MediaBrowser.Controller.Entities.Book; +using Movie = Jellyfin.Data.Entities.Movie; +using MusicAlbum = Jellyfin.Data.Entities.MusicAlbum; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Library Controller. + /// </summary> + [Route("")] + public class LibraryController : BaseJellyfinApiController + { + private readonly IProviderManager _providerManager; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly IAuthorizationContext _authContext; + private readonly IActivityManager _activityManager; + private readonly ILocalizationManager _localization; + private readonly ILibraryMonitor _libraryMonitor; + private readonly ILogger<LibraryController> _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="LibraryController"/> class. + /// </summary> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{LibraryController}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public LibraryController( + IProviderManager providerManager, + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService, + IAuthorizationContext authContext, + IActivityManager activityManager, + ILocalizationManager localization, + ILibraryMonitor libraryMonitor, + ILogger<LibraryController> logger, + IServerConfigurationManager serverConfigurationManager) + { + _providerManager = providerManager; + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + _authContext = authContext; + _activityManager = activityManager; + _localization = localization; + _libraryMonitor = libraryMonitor; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <summary> + /// Get the original file of an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">File stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="FileStreamResult"/> with the original file.</returns> + [HttpGet("Items/{itemId}/File")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetFile([FromRoute] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + using var fileStream = new FileStream(item.Path, FileMode.Open, FileAccess.Read); + return File(fileStream, MimeTypes.GetMimeType(item.Path)); + } + + /// <summary> + /// Gets critic review for an item. + /// </summary> + /// <response code="200">Critic reviews returned.</response> + /// <returns>The list of critic reviews.</returns> + [HttpGet("Items/{itemId}/CriticReviews")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [Obsolete("This endpoint is obsolete.")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetCriticReviews() + { + return new QueryResult<BaseItemDto>(); + } + + /// <summary> + /// Get theme songs for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <response code="200">Theme songs returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The item theme songs.</returns> + [HttpGet("Items/{itemId}/ThemeSongs")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<ThemeMediaResult> GetThemeSongs( + [FromRoute] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var item = itemId.Equals(Guid.Empty) + ? (!userId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.RootFolder) + : _libraryManager.GetItemById(itemId); + + if (item == null) + { + return NotFound("Item not found."); + } + + IEnumerable<BaseItem> themeItems; + + while (true) + { + themeItems = item.GetThemeSongs(); + + if (themeItems.Any() || !inheritFromParent) + { + break; + } + + var parent = item.GetParent(); + if (parent == null) + { + break; + } + + item = parent; + } + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); + + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } + + /// <summary> + /// Get theme videos for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <response code="200">Theme videos returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The item theme videos.</returns> + [HttpGet("Items/{itemId}/ThemeVideos")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<ThemeMediaResult> GetThemeVideos( + [FromRoute] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var item = itemId.Equals(Guid.Empty) + ? (!userId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.RootFolder) + : _libraryManager.GetItemById(itemId); + + if (item == null) + { + return NotFound("Item not found."); + } + + IEnumerable<BaseItem> themeItems; + + while (true) + { + themeItems = item.GetThemeVideos(); + + if (themeItems.Any() || !inheritFromParent) + { + break; + } + + var parent = item.GetParent(); + if (parent == null) + { + break; + } + + item = parent; + } + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); + + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } + + /// <summary> + /// Get theme songs and videos for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <response code="200">Theme songs and videos returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The item theme videos.</returns> + [HttpGet("Items/{itemId}/ThemeMedia")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<AllThemeMediaResult> GetThemeMedia( + [FromRoute] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + var themeSongs = GetThemeSongs( + itemId, + userId, + inheritFromParent); + + var themeVideos = GetThemeVideos( + itemId, + userId, + inheritFromParent); + + return new AllThemeMediaResult + { + ThemeSongsResult = themeSongs?.Value, + ThemeVideosResult = themeVideos?.Value, + SoundtrackSongsResult = new ThemeMediaResult() + }; + } + + /// <summary> + /// Starts a library scan. + /// </summary> + /// <response code="204">Library scan started.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpGet("Library/Refresh")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RefreshLibrary() + { + try + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing library"); + } + + return NoContent(); + } + + /// <summary> + /// Deletes an item from the library and filesystem. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="204">Item deleted.</response> + /// <response code="401">Unauthorized access.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Items/{itemId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public ActionResult DeleteItem(Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + var auth = _authContext.GetAuthorizationInfo(Request); + var user = auth.User; + + if (!item.CanDelete(user)) + { + return Unauthorized("Unauthorized access"); + } + + _libraryManager.DeleteItem( + item, + new DeleteOptions { DeleteFileLocation = true }, + true); + + return NoContent(); + } + + /// <summary> + /// Deletes items from the library and filesystem. + /// </summary> + /// <param name="ids">The item ids.</param> + /// <response code="204">Items deleted.</response> + /// <response code="401">Unauthorized access.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Items")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public ActionResult DeleteItems([FromQuery] string? ids) + { + if (string.IsNullOrEmpty(ids)) + { + return NoContent(); + } + + var itemIds = RequestHelpers.Split(ids, ',', true); + foreach (var i in itemIds) + { + var item = _libraryManager.GetItemById(i); + var auth = _authContext.GetAuthorizationInfo(Request); + var user = auth.User; + + if (!item.CanDelete(user)) + { + if (ids.Length > 1) + { + return Unauthorized("Unauthorized access"); + } + + continue; + } + + _libraryManager.DeleteItem( + item, + new DeleteOptions { DeleteFileLocation = true }, + true); + } + + return NoContent(); + } + + /// <summary> + /// Get item counts. + /// </summary> + /// <param name="userId">Optional. Get counts from a specific user's library.</param> + /// <param name="isFavorite">Optional. Get counts of favorite items.</param> + /// <response code="200">Item counts returned.</response> + /// <returns>Item counts.</returns> + [HttpGet("Items/Counts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ItemCounts> GetItemCounts( + [FromQuery] Guid? userId, + [FromQuery] bool? isFavorite) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var counts = new ItemCounts + { + AlbumCount = GetCount(typeof(MusicAlbum), user, isFavorite), + EpisodeCount = GetCount(typeof(Episode), user, isFavorite), + MovieCount = GetCount(typeof(Movie), user, isFavorite), + SeriesCount = GetCount(typeof(Series), user, isFavorite), + SongCount = GetCount(typeof(Audio), user, isFavorite), + MusicVideoCount = GetCount(typeof(MusicVideo), user, isFavorite), + BoxSetCount = GetCount(typeof(BoxSet), user, isFavorite), + BookCount = GetCount(typeof(Book), user, isFavorite) + }; + + return counts; + } + + /// <summary> + /// Gets all parents of an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Item parents returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>Item parents.</returns> + [HttpGet("Items/{itemId}/Ancestors")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute] Guid itemId, [FromQuery] Guid? userId) + { + var item = _libraryManager.GetItemById(itemId); + + if (item == null) + { + return NotFound("Item not found"); + } + + var baseItemDtos = new List<BaseItemDto>(); + + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var dtoOptions = new DtoOptions().AddClientFields(Request); + BaseItem parent = item.GetParent(); + + while (parent != null) + { + if (user != null) + { + parent = TranslateParentItem(parent, user); + } + + baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); + + parent = parent.GetParent(); + } + + return baseItemDtos; + } + + /// <summary> + /// Gets a list of physical paths from virtual folders. + /// </summary> + /// <response code="200">Physical paths returned.</response> + /// <returns>List of physical paths.</returns> + [HttpGet("Library/PhysicalPaths")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<string>> GetPhysicalPaths() + { + return Ok(_libraryManager.RootFolder.Children + .SelectMany(c => c.PhysicalLocations)); + } + + /// <summary> + /// Gets all user media folders. + /// </summary> + /// <param name="isHidden">Optional. Filter by folders that are marked hidden, or not.</param> + /// <response code="200">Media folders returned.</response> + /// <returns>List of user media folders.</returns> + [HttpGet("Library/MediaFolders")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden) + { + var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); + + if (isHidden.HasValue) + { + var val = isHidden.Value; + + items = items.Where(i => i.IsHidden == val).ToList(); + } + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var result = new QueryResult<BaseItemDto> + { + TotalRecordCount = items.Count, + Items = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions)).ToArray() + }; + + return result; + } + + /// <summary> + /// Reports that new episodes of a series have been added by an external source. + /// </summary> + /// <param name="tvdbId">The tvdbId.</param> + /// <response code="204">Report success.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Series/Added", Name = "PostAddedSeries")] + [HttpPost("Library/Series/Updated")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { nameof(Series) }, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } + }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); + + foreach (var item in series) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); + } + + return NoContent(); + } + + /// <summary> + /// Reports that new movies have been added by an external source. + /// </summary> + /// <param name="tmdbId">The tmdbId.</param> + /// <param name="imdbId">The imdbId.</param> + /// <response code="204">Report success.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Movies/Added", Name = "PostAddedMovies")] + [HttpPost("Library/Movies/Updated")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedMovies([FromRoute] string? tmdbId, [FromRoute] string? imdbId) + { + var movies = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { nameof(Movie) }, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } + }); + + if (!string.IsNullOrWhiteSpace(imdbId)) + { + movies = movies.Where(i => string.Equals(imdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList(); + } + else if (!string.IsNullOrWhiteSpace(tmdbId)) + { + movies = movies.Where(i => string.Equals(tmdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList(); + } + else + { + movies = new List<BaseItem>(); + } + + foreach (var item in movies) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); + } + + return NoContent(); + } + + /// <summary> + /// Reports that new movies have been added by an external source. + /// </summary> + /// <param name="updates">A list of updated media paths.</param> + /// <response code="204">Report success.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Media/Updated")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto[] updates) + { + foreach (var item in updates) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); + } + + return NoContent(); + } + + /// <summary> + /// Downloads item media. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">Media downloaded.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="FileResult"/> containing the media stream.</returns> + /// <exception cref="ArgumentException">User can't download or item can't be downloaded.</exception> + [HttpGet("Items/{itemId}/Download")] + [Authorize(Policy = Policies.Download)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetDownload([FromRoute] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + var auth = _authContext.GetAuthorizationInfo(Request); + + var user = auth.User; + + if (user != null) + { + if (!item.CanDownload(user)) + { + throw new ArgumentException("Item does not support downloading"); + } + } + else + { + if (!item.CanDownload()) + { + throw new ArgumentException("Item does not support downloading"); + } + } + + if (user != null) + { + LogDownload(item, user, auth); + } + + var path = item.Path; + + // Quotes are valid in linux. They'll possibly cause issues here + var filename = (Path.GetFileName(path) ?? string.Empty).Replace("\"", string.Empty, StringComparison.Ordinal); + if (!string.IsNullOrWhiteSpace(filename)) + { + // Kestrel doesn't support non-ASCII characters in headers + if (Regex.IsMatch(filename, @"[^\p{IsBasicLatin}]")) + { + // Manually encoding non-ASCII characters, following https://tools.ietf.org/html/rfc5987#section-3.2.2 + filename = WebUtility.UrlEncode(filename); + } + } + + // TODO determine non-ASCII validity. + return PhysicalFile(path, MimeTypes.GetMimeType(path)); + } + + /// <summary> + /// Gets similar items. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="excludeArtistIds">Exclude artist ids.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <response code="200">Similar items returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> + [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists2")] + [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")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( + [FromRoute] Guid itemId, + [FromQuery] string? excludeArtistIds, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] string? fields) + { + var item = itemId.Equals(Guid.Empty) + ? (!userId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _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))) + { + return new QueryResult<BaseItemDto>(); + } + + return GetSimilarItemsResult( + item, + excludeArtistIds, + userId, + limit, + fields, + new[] { item.GetType().Name }, + isMovie); + } + + /// <summary> + /// Gets the library options info. + /// </summary> + /// <param name="libraryContentType">Library content type.</param> + /// <param name="isNewLibrary">Whether this is a new library.</param> + /// <response code="200">Library options info returned.</response> + /// <returns>Library options info.</returns> + [HttpGet("Libraries/AvailableOptions")] + [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo( + [FromQuery] string? libraryContentType, + [FromQuery] bool isNewLibrary) + { + var result = new LibraryOptionsResultDto(); + + var types = GetRepresentativeItemTypes(libraryContentType); + var typesList = types.ToList(); + + var plugins = _providerManager.GetAllMetadataPlugins() + .Where(i => types.Contains(i.ItemType, StringComparer.OrdinalIgnoreCase)) + .OrderBy(i => typesList.IndexOf(i.ItemType)) + .ToList(); + + result.MetadataSavers = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary) + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(); + + result.MetadataReaders = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = true + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(); + + result.SubtitleFetchers = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = true + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(); + + var typeOptions = new List<LibraryTypeOptionsDto>(); + + foreach (var type in types) + { + TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); + + typeOptions.Add(new LibraryTypeOptionsDto + { + Type = type, + + MetadataFetchers = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary) + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(), + + ImageFetchers = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary) + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(), + + SupportedImageTypes = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>()) + .Distinct() + .ToArray(), + + DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>() + }); + } + + result.TypeOptions = typeOptions.ToArray(); + + return result; + } + + private int GetCount(Type type, User? user, bool? isFavorite) + { + var query = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { type.Name }, + Limit = 0, + Recursive = true, + IsVirtualItem = false, + IsFavorite = isFavorite, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } + }; + + return _libraryManager.GetItemsResult(query).TotalRecordCount; + } + + private BaseItem TranslateParentItem(BaseItem item, User user) + { + return item.GetParent() is AggregateFolder + ? _libraryManager.GetUserRootFolder().GetChildren(user, true) + .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path)) + : item; + } + + private void LogDownload(BaseItem item, User user, AuthorizationInfo auth) + { + try + { + _activityManager.Create(new ActivityLog( + string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), + "UserDownloadingContent", + auth.UserId) + { + ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), auth.Client, auth.Device), + }); + } + catch + { + // Logged at lower levels + } + } + + private QueryResult<BaseItemDto> 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<BaseItem> itemsResult; + + if (isMovie) + { + var itemTypes = new List<string> { 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<string>(); + + 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<BaseItemDto> + { + Items = returnList, + TotalRecordCount = itemsResult.Count + }; + + return result; + } + + private static string[] GetRepresentativeItemTypes(string? contentType) + { + return contentType switch + { + CollectionType.BoxSets => new[] { "BoxSet" }, + CollectionType.Playlists => new[] { "Playlist" }, + CollectionType.Movies => new[] { "Movie" }, + CollectionType.TvShows => new[] { "Series", "Season", "Episode" }, + CollectionType.Books => new[] { "Book" }, + CollectionType.Music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, + CollectionType.HomeVideos => new[] { "Video", "Photo" }, + CollectionType.Photos => new[] { "Video", "Photo" }, + CollectionType.MusicVideos => new[] { "MusicVideo" }, + _ => new[] { "Series", "Season", "Episode", "Movie" } + }; + } + + private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) + { + if (isNewLibrary) + { + return false; + } + + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + + return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparer.OrdinalIgnoreCase)); + } + + private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + { + if (isNewLibrary) + { + if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) + { + return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase)); + } + + return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase); + } + + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + return metadataOptions.Length == 0 + || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); + } + + private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + { + if (isNewLibrary) + { + if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) + { + return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); + } + + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (metadataOptions.Length == 0) + { + return true; + } + + return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); + } + } +} diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs new file mode 100644 index 000000000..cdab4f356 --- /dev/null +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.LibraryStructureDto; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The library structure controller. + /// </summary> + [Route("Library/VirtualFolders")] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + public class LibraryStructureController : BaseJellyfinApiController + { + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly ILibraryMonitor _libraryMonitor; + + /// <summary> + /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> + public LibraryStructureController( + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor) + { + _appPaths = serverConfigurationManager.ApplicationPaths; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + } + + /// <summary> + /// Gets all virtual folders. + /// </summary> + /// <response code="200">Virtual folders retrieved.</response> + /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders() + { + return _libraryManager.GetVirtualFolders(true); + } + + /// <summary> + /// Adds a virtual folder. + /// </summary> + /// <param name="name">The name of the virtual folder.</param> + /// <param name="collectionType">The type of the collection.</param> + /// <param name="paths">The paths of the virtual folder.</param> + /// <param name="libraryOptionsDto">The library options.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder added.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> AddVirtualFolder( + [FromQuery] string? name, + [FromQuery] string? collectionType, + [FromQuery] string[] paths, + [FromBody] LibraryOptionsDto? libraryOptionsDto, + [FromQuery] bool refreshLibrary = false) + { + var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions(); + + if (paths != null && paths.Length > 0) + { + libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo { Path = i }).ToArray(); + } + + await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Removes a virtual folder. + /// </summary> + /// <param name="name">The name of the folder.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder removed.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RemoveVirtualFolder( + [FromQuery] string? name, + [FromQuery] bool refreshLibrary = false) + { + await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Renames a virtual folder. + /// </summary> + /// <param name="name">The name of the virtual folder.</param> + /// <param name="newName">The new name.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder renamed.</response> + /// <response code="404">Library doesn't exist.</response> + /// <response code="409">Library already exists.</response> + /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> + /// <exception cref="ArgumentNullException">The new name may not be null.</exception> + [HttpPost("Name")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public ActionResult RenameVirtualFolder( + [FromQuery] string? name, + [FromQuery] string? newName, + [FromQuery] bool refreshLibrary = false) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrWhiteSpace(newName)) + { + throw new ArgumentNullException(nameof(newName)); + } + + var rootFolderPath = _appPaths.DefaultUserViewsPath; + + var currentPath = Path.Combine(rootFolderPath, name); + var newPath = Path.Combine(rootFolderPath, newName); + + if (!Directory.Exists(currentPath)) + { + return NotFound("The media collection does not exist."); + } + + if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath)) + { + return Conflict($"The media library already exists at {newPath}."); + } + + _libraryMonitor.Stop(); + + try + { + // Changing capitalization. Handle windows case insensitivity + if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase)) + { + var tempPath = Path.Combine( + rootFolderPath, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); + Directory.Move(currentPath, tempPath); + currentPath = tempPath; + } + + Directory.Move(currentPath, newPath); + } + finally + { + CollectionFolder.OnCollectionFolderChange(); + + Task.Run(async () => + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); + } + + return NoContent(); + } + + /// <summary> + /// Add a media path to a library. + /// </summary> + /// <param name="mediaPathDto">The media path dto.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path added.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpPost("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddMediaPath( + [FromBody, Required] MediaPathDto mediaPathDto, + [FromQuery] bool refreshLibrary = false) + { + _libraryMonitor.Stop(); + + try + { + var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo { Path = mediaPathDto.Path }; + + _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath); + } + finally + { + Task.Run(async () => + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); + } + + return NoContent(); + } + + /// <summary> + /// Updates a media path. + /// </summary> + /// <param name="name">The name of the library.</param> + /// <param name="pathInfo">The path info.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path updated.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpPost("Paths/Update")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaPath( + [FromQuery] string? name, + [FromBody] MediaPathInfo? pathInfo) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + _libraryManager.UpdateMediaPath(name, pathInfo); + return NoContent(); + } + + /// <summary> + /// Remove a media path. + /// </summary> + /// <param name="name">The name of the library.</param> + /// <param name="path">The path to remove.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path removed.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpDelete("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveMediaPath( + [FromQuery] string? name, + [FromQuery] string? path, + [FromQuery] bool refreshLibrary = false) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + _libraryMonitor.Stop(); + + try + { + _libraryManager.RemoveMediaPath(name, path); + } + finally + { + Task.Run(async () => + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); + } + + return NoContent(); + } + + /// <summary> + /// Update library options. + /// </summary> + /// <param name="id">The library name.</param> + /// <param name="libraryOptions">The library options.</param> + /// <response code="204">Library updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("LibraryOptions")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateLibraryOptions( + [FromQuery] string? id, + [FromBody] LibraryOptions? libraryOptions) + { + var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(id); + + collectionFolder.UpdateLibraryOptions(libraryOptions); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs new file mode 100644 index 000000000..89112eea7 --- /dev/null +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -0,0 +1,1238 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.LiveTvDtos; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Live tv controller. + /// </summary> + public class LiveTvController : BaseJellyfinApiController + { + private readonly ILiveTvManager _liveTvManager; + private readonly IUserManager _userManager; + private readonly IHttpClient _httpClient; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly ISessionContext _sessionContext; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IConfigurationManager _configurationManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + + /// <summary> + /// Initializes a new instance of the <see cref="LiveTvController"/> class. + /// </summary> + /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="sessionContext">Instance of the <see cref="ISessionContext"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> + public LiveTvController( + ILiveTvManager liveTvManager, + IUserManager userManager, + IHttpClient httpClient, + ILibraryManager libraryManager, + IDtoService dtoService, + ISessionContext sessionContext, + IMediaSourceManager mediaSourceManager, + IConfigurationManager configurationManager, + TranscodingJobHelper transcodingJobHelper) + { + _liveTvManager = liveTvManager; + _userManager = userManager; + _httpClient = httpClient; + _libraryManager = libraryManager; + _dtoService = dtoService; + _sessionContext = sessionContext; + _mediaSourceManager = mediaSourceManager; + _configurationManager = configurationManager; + _transcodingJobHelper = transcodingJobHelper; + } + + /// <summary> + /// Gets available live tv services. + /// </summary> + /// <response code="200">Available live tv services returned.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the available live tv services. + /// </returns> + [HttpGet("Info")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<LiveTvInfo> GetLiveTvInfo() + { + return _liveTvManager.GetLiveTvInfo(CancellationToken.None); + } + + /// <summary> + /// Gets available live tv channels. + /// </summary> + /// <param name="type">Optional. Filter by channel type.</param> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="isMovie">Optional. Filter for movies.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isNews">Optional. Filter for news.</param> + /// <param name="isKids">Optional. Filter for kids.</param> + /// <param name="isSports">Optional. Filter for sports.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="isFavorite">Optional. Filter by channels that are favorites, or not.</param> + /// <param name="isLiked">Optional. Filter by channels that are liked, or not.</param> + /// <param name="isDisliked">Optional. Filter by channels that are disliked, or not.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param> + /// <param name="fields">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.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="sortBy">Optional. Key to sort by.</param> + /// <param name="sortOrder">Optional. Sort order.</param> + /// <param name="enableFavoriteSorting">Optional. Incorporate favorite and like status into channel sorting.</param> + /// <param name="addCurrentProgram">Optional. Adds current program info to each channel.</param> + /// <response code="200">Available live tv channels returned.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the resulting available live tv channels. + /// </returns> + [HttpGet("Channels")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<QueryResult<BaseItemDto>> GetLiveTvChannels( + [FromQuery] ChannelType? type, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] int? limit, + [FromQuery] bool? isFavorite, + [FromQuery] bool? isLiked, + [FromQuery] bool? isDisliked, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] string? fields, + [FromQuery] bool? enableUserData, + [FromQuery] string? sortBy, + [FromQuery] SortOrder? sortOrder, + [FromQuery] bool enableFavoriteSorting = false, + [FromQuery] bool addCurrentProgram = true) + { + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var channelResult = _liveTvManager.GetInternalChannels( + new LiveTvChannelQuery + { + ChannelType = type, + UserId = userId ?? Guid.Empty, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + IsLiked = isLiked, + IsDisliked = isDisliked, + EnableFavoriteSorting = enableFavoriteSorting, + IsMovie = isMovie, + IsSeries = isSeries, + IsNews = isNews, + IsKids = isKids, + IsSports = isSports, + SortBy = RequestHelpers.Split(sortBy, ',', true), + SortOrder = sortOrder ?? SortOrder.Ascending, + AddCurrentProgram = addCurrentProgram + }, + dtoOptions, + CancellationToken.None); + + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var fieldsList = dtoOptions.Fields.ToList(); + fieldsList.Remove(ItemFields.CanDelete); + fieldsList.Remove(ItemFields.CanDownload); + fieldsList.Remove(ItemFields.DisplayPreferencesId); + fieldsList.Remove(ItemFields.Etag); + dtoOptions.Fields = fieldsList.ToArray(); + dtoOptions.AddCurrentProgram = addCurrentProgram; + + var returnArray = _dtoService.GetBaseItemDtos(channelResult.Items, dtoOptions, user); + return new QueryResult<BaseItemDto> + { + Items = returnArray, + TotalRecordCount = channelResult.TotalRecordCount + }; + } + + /// <summary> + /// Gets a live tv channel. + /// </summary> + /// <param name="channelId">Channel id.</param> + /// <param name="userId">Optional. Attach user data.</param> + /// <response code="200">Live tv channel returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns> + [HttpGet("Channels/{channelId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<BaseItemDto> GetChannel([FromRoute] Guid channelId, [FromQuery] Guid? userId) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var item = channelId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(channelId); + + var dtoOptions = new DtoOptions() + .AddClientFields(Request); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + /// <summary> + /// Gets live tv recordings. + /// </summary> + /// <param name="channelId">Optional. Filter by channel id.</param> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="status">Optional. Filter by recording status.</param> + /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> + /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="fields">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.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="isMovie">Optional. Filter for movies.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isKids">Optional. Filter for kids.</param> + /// <param name="isSports">Optional. Filter for sports.</param> + /// <param name="isNews">Optional. Filter for news.</param> + /// <param name="isLibraryItem">Optional. Filter for is library item.</param> + /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> + /// <response code="200">Live tv recordings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> + [HttpGet("Recordings")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<QueryResult<BaseItemDto>> GetRecordings( + [FromQuery] string? channelId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] RecordingStatus? status, + [FromQuery] bool? isInProgress, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] string? fields, + [FromQuery] bool? enableUserData, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool? isNews, + [FromQuery] bool? isLibraryItem, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + return _liveTvManager.GetRecordings( + new RecordingQuery + { + ChannelId = channelId, + UserId = userId ?? Guid.Empty, + StartIndex = startIndex, + Limit = limit, + Status = status, + SeriesTimerId = seriesTimerId, + IsInProgress = isInProgress, + EnableTotalRecordCount = enableTotalRecordCount, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsKids = isKids, + IsSports = isSports, + IsLibraryItem = isLibraryItem, + Fields = RequestHelpers.GetItemFields(fields), + ImageTypeLimit = imageTypeLimit, + EnableImages = enableImages + }, dtoOptions); + } + + /// <summary> + /// Gets live tv recording series. + /// </summary> + /// <param name="channelId">Optional. Filter by channel id.</param> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <param name="groupId">Optional. Filter by recording group.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="status">Optional. Filter by recording status.</param> + /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> + /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="fields">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.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> + /// <response code="200">Live tv recordings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> + [HttpGet("Recordings/Series")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + [Obsolete("This endpoint is obsolete.")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")] + public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries( + [FromQuery] string? channelId, + [FromQuery] Guid? userId, + [FromQuery] string? groupId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] RecordingStatus? status, + [FromQuery] bool? isInProgress, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] string? fields, + [FromQuery] bool? enableUserData, + [FromQuery] bool enableTotalRecordCount = true) + { + return new QueryResult<BaseItemDto>(); + } + + /// <summary> + /// Gets live tv recording groups. + /// </summary> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <response code="200">Recording groups returned.</response> + /// <returns>An <see cref="OkResult"/> containing the recording groups.</returns> + [HttpGet("Recordings/Groups")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + [Obsolete("This endpoint is obsolete.")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId) + { + return new QueryResult<BaseItemDto>(); + } + + /// <summary> + /// Gets recording folders. + /// </summary> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <response code="200">Recording folders returned.</response> + /// <returns>An <see cref="OkResult"/> containing the recording folders.</returns> + [HttpGet("Recordings/Folders")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<QueryResult<BaseItemDto>> GetRecordingFolders([FromQuery] Guid? userId) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var folders = _liveTvManager.GetRecordingFolders(user); + + var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user); + + return new QueryResult<BaseItemDto> + { + Items = returnArray, + TotalRecordCount = returnArray.Count + }; + } + + /// <summary> + /// Gets a live tv recording. + /// </summary> + /// <param name="recordingId">Recording id.</param> + /// <param name="userId">Optional. Attach user data.</param> + /// <response code="200">Recording returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns> + [HttpGet("Recordings/{recordingId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<BaseItemDto> GetRecording([FromRoute] Guid recordingId, [FromQuery] Guid? userId) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var item = recordingId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId); + + var dtoOptions = new DtoOptions() + .AddClientFields(Request); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + /// <summary> + /// Resets a tv tuner. + /// </summary> + /// <param name="tunerId">Tuner id.</param> + /// <response code="204">Tuner reset.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Tuners/{tunerId}/Reset")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult ResetTuner([FromRoute] string tunerId) + { + AssertUserCanManageLiveTv(); + _liveTvManager.ResetTuner(tunerId, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Gets a timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="200">Timer returned.</response> + /// <returns> + /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the timer. + /// </returns> + [HttpGet("Timers/{timerId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<TimerInfoDto>> GetTimer(string timerId) + { + return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Gets the default values for a new timer. + /// </summary> + /// <param name="programId">Optional. To attach default values based on a program.</param> + /// <response code="200">Default values returned.</response> + /// <returns> + /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the default values for a timer. + /// </returns> + [HttpGet("Timers/Defaults")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<SeriesTimerInfoDto>> GetDefaultTimer([FromQuery] string? programId) + { + return string.IsNullOrEmpty(programId) + ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false) + : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Gets the live tv timers. + /// </summary> + /// <param name="channelId">Optional. Filter by channel id.</param> + /// <param name="seriesTimerId">Optional. Filter by timers belonging to a series timer.</param> + /// <param name="isActive">Optional. Filter by timers that are active.</param> + /// <param name="isScheduled">Optional. Filter by timers that are scheduled.</param> + /// <returns> + /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the live tv timers. + /// </returns> + [HttpGet("Timers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<QueryResult<TimerInfoDto>>> GetTimers( + [FromQuery] string? channelId, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? isActive, + [FromQuery] bool? isScheduled) + { + return await _liveTvManager.GetTimers( + new TimerQuery + { + ChannelId = channelId, + SeriesTimerId = seriesTimerId, + IsActive = isActive, + IsScheduled = isScheduled + }, CancellationToken.None) + .ConfigureAwait(false); + } + + /// <summary> + /// Gets available live tv epgs. + /// </summary> + /// <param name="channelIds">The channels to return guide information for.</param> + /// <param name="userId">Optional. Filter by user id.</param> + /// <param name="minStartDate">Optional. The minimum premiere start date.</param> + /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> + /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> + /// <param name="maxStartDate">Optional. The maximum premiere start date.</param> + /// <param name="minEndDate">Optional. The minimum premiere end date.</param> + /// <param name="maxEndDate">Optional. The maximum premiere end date.</param> + /// <param name="isMovie">Optional. Filter for movies.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isNews">Optional. Filter for news.</param> + /// <param name="isKids">Optional. Filter for kids.</param> + /// <param name="isSports">Optional. Filter for sports.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="genres">The genres to return guide information for.</param> + /// <param name="genreIds">The genre ids to return guide information for.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="seriesTimerId">Optional. Filter by series timer id.</param> + /// <param name="librarySeriesId">Optional. Filter by library series id.</param> + /// <param name="fields">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.</param> + /// <param name="enableTotalRecordCount">Retrieve total record count.</param> + /// <response code="200">Live tv epgs returned.</response> + /// <returns> + /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. + /// </returns> + [HttpGet("Programs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms( + [FromQuery] string? channelIds, + [FromQuery] Guid? userId, + [FromQuery] DateTime? minStartDate, + [FromQuery] bool? hasAired, + [FromQuery] bool? isAiring, + [FromQuery] DateTime? maxStartDate, + [FromQuery] DateTime? minEndDate, + [FromQuery] DateTime? maxEndDate, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? sortBy, + [FromQuery] string? sortOrder, + [FromQuery] string? genres, + [FromQuery] string? genreIds, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] string? seriesTimerId, + [FromQuery] Guid? librarySeriesId, + [FromQuery] string? fields, + [FromQuery] bool enableTotalRecordCount = true) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var query = new InternalItemsQuery(user) + { + ChannelIds = RequestHelpers.Split(channelIds, ',', true) + .Select(i => new Guid(i)).ToArray(), + HasAired = hasAired, + IsAiring = isAiring, + EnableTotalRecordCount = enableTotalRecordCount, + MinStartDate = minStartDate, + MinEndDate = minEndDate, + MaxStartDate = maxStartDate, + MaxEndDate = maxEndDate, + StartIndex = startIndex, + Limit = limit, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + IsNews = isNews, + IsMovie = isMovie, + IsSeries = isSeries, + IsKids = isKids, + IsSports = isSports, + SeriesTimerId = seriesTimerId, + Genres = RequestHelpers.Split(genres, ',', true), + GenreIds = RequestHelpers.GetGuids(genreIds) + }; + + if (!librarySeriesId.Equals(Guid.Empty)) + { + query.IsSeries = true; + + if (_libraryManager.GetItemById(librarySeriesId ?? Guid.Empty) is Series series) + { + query.Name = series.Name; + } + } + + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Gets available live tv epgs. + /// </summary> + /// <param name="body">Request body.</param> + /// <response code="200">Live tv epgs returned.</response> + /// <returns> + /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. + /// </returns> + [HttpPost("Programs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body) + { + var user = body.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(body.UserId); + + var query = new InternalItemsQuery(user) + { + ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true) + .Select(i => new Guid(i)).ToArray(), + HasAired = body.HasAired, + IsAiring = body.IsAiring, + EnableTotalRecordCount = body.EnableTotalRecordCount, + MinStartDate = body.MinStartDate, + MinEndDate = body.MinEndDate, + MaxStartDate = body.MaxStartDate, + MaxEndDate = body.MaxEndDate, + StartIndex = body.StartIndex, + Limit = body.Limit, + OrderBy = RequestHelpers.GetOrderBy(body.SortBy, body.SortOrder), + IsNews = body.IsNews, + IsMovie = body.IsMovie, + IsSeries = body.IsSeries, + IsKids = body.IsKids, + IsSports = body.IsSports, + SeriesTimerId = body.SeriesTimerId, + Genres = RequestHelpers.Split(body.Genres, ',', true), + GenreIds = RequestHelpers.GetGuids(body.GenreIds) + }; + + if (!body.LibrarySeriesId.Equals(Guid.Empty)) + { + query.IsSeries = true; + + if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series) + { + query.Name = series.Name; + } + } + + var dtoOptions = new DtoOptions() + .AddItemFields(body.Fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes); + return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Gets recommended live tv epgs. + /// </summary> + /// <param name="userId">Optional. filter by user id.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> + /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isMovie">Optional. Filter for movies.</param> + /// <param name="isNews">Optional. Filter for news.</param> + /// <param name="isKids">Optional. Filter for kids.</param> + /// <param name="isSports">Optional. Filter for sports.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="genreIds">The genres to return guide information for.</param> + /// <param name="fields">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.</param> + /// <param name="enableUserData">Optional. include user data.</param> + /// <param name="enableTotalRecordCount">Retrieve total record count.</param> + /// <response code="200">Recommended epgs returned.</response> + /// <returns>A <see cref="OkResult"/> containing the queryresult of recommended epgs.</returns> + [HttpGet("Programs/Recommended")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetRecommendedPrograms( + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] bool? isAiring, + [FromQuery] bool? hasAired, + [FromQuery] bool? isSeries, + [FromQuery] bool? isMovie, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] string? genreIds, + [FromQuery] string? fields, + [FromQuery] bool? enableUserData, + [FromQuery] bool enableTotalRecordCount = true) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var query = new InternalItemsQuery(user) + { + IsAiring = isAiring, + Limit = limit, + HasAired = hasAired, + IsSeries = isSeries, + IsMovie = isMovie, + IsKids = isKids, + IsNews = isNews, + IsSports = isSports, + EnableTotalRecordCount = enableTotalRecordCount, + GenreIds = RequestHelpers.GetGuids(genreIds) + }; + + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return _liveTvManager.GetRecommendedPrograms(query, dtoOptions, CancellationToken.None); + } + + /// <summary> + /// Gets a live tv program. + /// </summary> + /// <param name="programId">Program id.</param> + /// <param name="userId">Optional. Attach user data.</param> + /// <response code="200">Program returned.</response> + /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns> + [HttpGet("Programs/{programId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<BaseItemDto>> GetProgram( + [FromRoute] string programId, + [FromQuery] Guid? userId) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); + } + + /// <summary> + /// Deletes a live tv recording. + /// </summary> + /// <param name="recordingId">Recording id.</param> + /// <response code="204">Recording deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpDelete("Recordings/{recordingId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteRecording([FromRoute] Guid recordingId) + { + AssertUserCanManageLiveTv(); + + var item = _libraryManager.GetItemById(recordingId); + if (item == null) + { + return NotFound(); + } + + _libraryManager.DeleteItem(item, new DeleteOptions + { + DeleteFileLocation = false + }); + + return NoContent(); + } + + /// <summary> + /// Cancels a live tv timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="204">Timer deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Timers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CancelTimer([FromRoute] string timerId) + { + AssertUserCanManageLiveTv(); + await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Updates a live tv timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <param name="timerInfo">New timer info.</param> + /// <response code="204">Timer updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Timers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> UpdateTimer([FromRoute] string timerId, [FromBody] TimerInfoDto timerInfo) + { + AssertUserCanManageLiveTv(); + await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Creates a live tv timer. + /// </summary> + /// <param name="timerInfo">New timer info.</param> + /// <response code="204">Timer created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Timers")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CreateTimer([FromBody] TimerInfoDto timerInfo) + { + AssertUserCanManageLiveTv(); + await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Gets a live tv series timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="200">Series timer returned.</response> + /// <response code="404">Series timer not found.</response> + /// <returns>A <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if timer not found.</returns> + [HttpGet("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute] string timerId) + { + var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false); + if (timer == null) + { + return NotFound(); + } + + return timer; + } + + /// <summary> + /// Gets live tv series timers. + /// </summary> + /// <param name="sortBy">Optional. Sort by SortName or Priority.</param> + /// <param name="sortOrder">Optional. Sort in Ascending or Descending order.</param> + /// <response code="200">Timers returned.</response> + /// <returns>An <see cref="OkResult"/> of live tv series timers.</returns> + [HttpGet("SeriesTimers")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<SeriesTimerInfoDto>>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder) + { + return await _liveTvManager.GetSeriesTimers( + new SeriesTimerQuery + { + SortOrder = sortOrder ?? SortOrder.Ascending, + SortBy = sortBy + }, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Cancels a live tv series timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="204">Timer cancelled.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CancelSeriesTimer([FromRoute] string timerId) + { + AssertUserCanManageLiveTv(); + await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Updates a live tv series timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <param name="seriesTimerInfo">New series timer info.</param> + /// <response code="204">Series timer updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> UpdateSeriesTimer([FromRoute] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) + { + AssertUserCanManageLiveTv(); + await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Creates a live tv series timer. + /// </summary> + /// <param name="seriesTimerInfo">New series timer info.</param> + /// <response code="204">Series timer info created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("SeriesTimers")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo) + { + AssertUserCanManageLiveTv(); + await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Get recording group. + /// </summary> + /// <param name="groupId">Group id.</param> + /// <returns>A <see cref="NotFoundResult"/>.</returns> + [HttpGet("Recordings/Groups/{groupId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("This endpoint is obsolete.")] + public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute] Guid? groupId) + { + return NotFound(); + } + + /// <summary> + /// Get guid info. + /// </summary> + /// <response code="200">Guid info returned.</response> + /// <returns>An <see cref="OkResult"/> containing the guide info.</returns> + [HttpGet("GuideInfo")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<GuideInfo> GetGuideInfo() + { + return _liveTvManager.GetGuideInfo(); + } + + /// <summary> + /// Adds a tuner host. + /// </summary> + /// <param name="tunerHostInfo">New tuner host.</param> + /// <response code="200">Created tuner host returned.</response> + /// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns> + [HttpPost("TunerHosts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo) + { + return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); + } + + /// <summary> + /// Deletes a tuner host. + /// </summary> + /// <param name="id">Tuner host id.</param> + /// <response code="204">Tuner host deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("TunerHosts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteTunerHost([FromQuery] string? id) + { + var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); + config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); + _configurationManager.SaveConfiguration("livetv", config); + return NoContent(); + } + + /// <summary> + /// Gets default listings provider info. + /// </summary> + /// <response code="200">Default listings provider info returned.</response> + /// <returns>An <see cref="OkResult"/> containing the default listings provider info.</returns> + [HttpGet("ListingProviders/Default")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ListingsProviderInfo> GetDefaultListingProvider() + { + return new ListingsProviderInfo(); + } + + /// <summary> + /// Adds a listings provider. + /// </summary> + /// <param name="pw">Password.</param> + /// <param name="listingsProviderInfo">New listings info.</param> + /// <param name="validateListings">Validate listings.</param> + /// <param name="validateLogin">Validate login.</param> + /// <response code="200">Created listings provider returned.</response> + /// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns> + [HttpGet("ListingProviders")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")] + public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider( + [FromQuery] string? pw, + [FromBody] ListingsProviderInfo listingsProviderInfo, + [FromQuery] bool validateListings = false, + [FromQuery] bool validateLogin = false) + { + using var sha = SHA1.Create(); + if (!string.IsNullOrEmpty(pw)) + { + listingsProviderInfo.Password = Hex.Encode(sha.ComputeHash(Encoding.UTF8.GetBytes(pw))); + } + + return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); + } + + /// <summary> + /// Delete listing provider. + /// </summary> + /// <param name="id">Listing provider id.</param> + /// <response code="204">Listing provider deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("ListingProviders")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteListingProvider([FromQuery] string? id) + { + _liveTvManager.DeleteListingsProvider(id); + return NoContent(); + } + + /// <summary> + /// Gets available lineups. + /// </summary> + /// <param name="id">Provider id.</param> + /// <param name="type">Provider type.</param> + /// <param name="location">Location.</param> + /// <param name="country">Country.</param> + /// <response code="200">Available lineups returned.</response> + /// <returns>A <see cref="OkResult"/> containing the available lineups.</returns> + [HttpGet("ListingProviders/Lineups")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<NameIdPair>>> GetLineups( + [FromQuery] string? id, + [FromQuery] string? type, + [FromQuery] string? location, + [FromQuery] string? country) + { + return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); + } + + /// <summary> + /// Gets available countries. + /// </summary> + /// <response code="200">Available countries returned.</response> + /// <returns>A <see cref="FileResult"/> containing the available countries.</returns> + [HttpGet("ListingProviders/SchedulesDirect/Countries")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetSchedulesDirectCountries() + { + // https://json.schedulesdirect.org/20141201/available/countries + var response = await _httpClient.Get(new HttpRequestOptions + { + Url = "https://json.schedulesdirect.org/20141201/available/countries", + BufferContent = false + }).ConfigureAwait(false); + return File(response, MediaTypeNames.Application.Json); + } + + /// <summary> + /// Get channel mapping options. + /// </summary> + /// <param name="providerId">Provider id.</param> + /// <response code="200">Channel mapping options returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns> + [HttpGet("ChannelMappingOptions")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId) + { + var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); + + var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); + + var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; + + var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) + .ConfigureAwait(false); + + var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) + .ConfigureAwait(false); + + var mappings = listingsProviderInfo.ChannelMappings; + + return new ChannelMappingOptionsDto + { + TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), + ProviderChannels = providerChannels.Select(i => new NameIdPair + { + Name = i.Name, + Id = i.Id + }).ToList(), + Mappings = mappings, + ProviderName = listingsProviderName + }; + } + + /// <summary> + /// Set channel mappings. + /// </summary> + /// <param name="providerId">Provider id.</param> + /// <param name="tunerChannelId">Tuner channel id.</param> + /// <param name="providerChannelId">Provider channel id.</param> + /// <response code="200">Created channel mapping returned.</response> + /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns> + [HttpPost("ChannelMappings")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping( + [FromQuery] string? providerId, + [FromQuery] string? tunerChannelId, + [FromQuery] string? providerChannelId) + { + return await _liveTvManager.SetChannelMapping(providerId, tunerChannelId, providerChannelId).ConfigureAwait(false); + } + + /// <summary> + /// Get tuner host types. + /// </summary> + /// <response code="200">Tuner host types returned.</response> + /// <returns>An <see cref="OkResult"/> containing the tuner host types.</returns> + [HttpGet("TunerHosts/Types")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<NameIdPair>> GetTunerHostTypes() + { + return _liveTvManager.GetTunerHostTypes(); + } + + /// <summary> + /// Discover tuners. + /// </summary> + /// <param name="newDevicesOnly">Only discover new tuners.</param> + /// <response code="200">Tuners returned.</response> + /// <returns>An <see cref="OkResult"/> containing the tuners.</returns> + [HttpGet("Tuners/Discvover")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) + { + return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Gets a live tv recording stream. + /// </summary> + /// <param name="recordingId">Recording id.</param> + /// <response code="200">Recording stream returned.</response> + /// <response code="404">Recording not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the recording stream on success, + /// or a <see cref="NotFoundResult"/> if recording not found. + /// </returns> + [HttpGet("LiveRecordings/{recordingId}/stream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetLiveRecordingFile([FromRoute] string recordingId) + { + var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); + + if (string.IsNullOrWhiteSpace(path)) + { + return NotFound(); + } + + await using var memoryStream = new MemoryStream(); + await new ProgressiveFileCopier(path, null, _transcodingJobHelper, CancellationToken.None) + .WriteToAsync(memoryStream, CancellationToken.None) + .ConfigureAwait(false); + return File(memoryStream, MimeTypes.GetMimeType(path)); + } + + /// <summary> + /// Gets a live tv channel stream. + /// </summary> + /// <param name="streamId">Stream id.</param> + /// <param name="container">Container type.</param> + /// <response code="200">Stream returned.</response> + /// <response code="404">Stream not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the channel stream on success, + /// or a <see cref="NotFoundResult"/> if stream not found. + /// </returns> + [HttpGet("LiveStreamFiles/{streamId}/stream.{container}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetLiveStreamFile([FromRoute] string streamId, [FromRoute] string container) + { + var liveStreamInfo = await _mediaSourceManager.GetDirectStreamProviderByUniqueId(streamId, CancellationToken.None).ConfigureAwait(false); + if (liveStreamInfo == null) + { + 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)); + } + + private void AssertUserCanManageLiveTv() + { + var user = _sessionContext.GetUser(Request); + + if (user == null) + { + throw new SecurityException("Anonymous live tv management is not allowed."); + } + + if (!user.HasPermission(PermissionKind.EnableLiveTvManagement)) + { + throw new SecurityException("The current user does not have permission to manage live tv."); + } + } + } +} diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs new file mode 100644 index 000000000..ef2e7e8b1 --- /dev/null +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using Jellyfin.Api.Constants; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Localization controller. + /// </summary> + [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] + public class LocalizationController : BaseJellyfinApiController + { + private readonly ILocalizationManager _localization; + + /// <summary> + /// Initializes a new instance of the <see cref="LocalizationController"/> class. + /// </summary> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + public LocalizationController(ILocalizationManager localization) + { + _localization = localization; + } + + /// <summary> + /// Gets known cultures. + /// </summary> + /// <response code="200">Known cultures returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns> + [HttpGet("Cultures")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<CultureDto>> GetCultures() + { + return Ok(_localization.GetCultures()); + } + + /// <summary> + /// Gets known countries. + /// </summary> + /// <response code="200">Known countries returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns> + [HttpGet("Countries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<CountryInfo>> GetCountries() + { + return Ok(_localization.GetCountries()); + } + + /// <summary> + /// Gets known parental ratings. + /// </summary> + /// <response code="200">Known parental ratings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns> + [HttpGet("ParentalRatings")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings() + { + return Ok(_localization.GetParentalRatings()); + } + + /// <summary> + /// Gets localization options. + /// </summary> + /// <response code="200">Localization options returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns> + [HttpGet("Options")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions() + { + return Ok(_localization.GetLocalizationOptions()); + } + } +} diff --git a/MediaBrowser.Api/Playback/MediaInfoService.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index b7ca1a031..517113074 100644 --- a/MediaBrowser.Api/Playback/MediaInfoService.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -1,14 +1,16 @@ -#pragma warning disable CS1591 -#pragma warning disable SA1402 -#pragma warning disable SA1649 - -using System; +using System; using System.Buffers; +using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; +using System.Net.Mime; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.MediaInfoDtos; +using Jellyfin.Api.Models.VideoDtos; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; @@ -22,54 +24,20 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Services; using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace MediaBrowser.Api.Playback +namespace Jellyfin.Api.Controllers { - [Route("/Items/{Id}/PlaybackInfo", "GET", Summary = "Gets live playback media info for an item")] - public class GetPlaybackInfo : IReturn<PlaybackInfoResponse> - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - [Route("/Items/{Id}/PlaybackInfo", "POST", Summary = "Gets live playback media info for an item")] - public class GetPostedPlaybackInfo : PlaybackInfoRequest, IReturn<PlaybackInfoResponse> - { - } - - [Route("/LiveStreams/Open", "POST", Summary = "Opens a media source")] - public class OpenMediaSource : LiveStreamRequest, IReturn<LiveStreamResponse> - { - } - - [Route("/LiveStreams/Close", "POST", Summary = "Closes a media source")] - public class CloseMediaSource : IReturnVoid - { - [ApiMember(Name = "LiveStreamId", Description = "LiveStreamId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string LiveStreamId { get; set; } - } - - [Route("/Playback/BitrateTest", "GET")] - public class GetBitrateTestBytes - { - [ApiMember(Name = "Size", Description = "Size", IsRequired = true, DataType = "int", ParameterType = "query", Verb = "GET")] - public int Size { get; set; } - - public GetBitrateTestBytes() - { - // 100k - Size = 102400; - } - } - - [Authenticated] - public class MediaInfoService : BaseApiService + /// <summary> + /// The media info controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class MediaInfoController : BaseJellyfinApiController { private readonly IMediaSourceManager _mediaSourceManager; private readonly IDeviceManager _deviceManager; @@ -78,19 +46,31 @@ namespace MediaBrowser.Api.Playback private readonly IMediaEncoder _mediaEncoder; private readonly IUserManager _userManager; private readonly IAuthorizationContext _authContext; - - public MediaInfoService( - ILogger<MediaInfoService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, + private readonly ILogger<MediaInfoController> _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MediaInfoController"/> class. + /// </summary> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public MediaInfoController( IMediaSourceManager mediaSourceManager, IDeviceManager deviceManager, ILibraryManager libraryManager, INetworkManager networkManager, IMediaEncoder mediaEncoder, IUserManager userManager, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) + IAuthorizationContext authContext, + ILogger<MediaInfoController> logger, + IServerConfigurationManager serverConfigurationManager) { _mediaSourceManager = mediaSourceManager; _deviceManager = deviceManager; @@ -99,56 +79,71 @@ namespace MediaBrowser.Api.Playback _mediaEncoder = mediaEncoder; _userManager = userManager; _authContext = authContext; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; } - public object Get(GetBitrateTestBytes request) - { - const int MaxSize = 10_000_000; - - var size = request.Size; - - if (size <= 0) - { - throw new ArgumentException($"The requested size ({size}) is equal to or smaller than 0.", nameof(request)); - } - - if (size > MaxSize) - { - throw new ArgumentException($"The requested size ({size}) is larger than the max allowed value ({MaxSize}).", nameof(request)); - } - - byte[] buffer = ArrayPool<byte>.Shared.Rent(size); - try - { - new Random().NextBytes(buffer); - return ResultFactory.GetResult(null, buffer, "application/octet-stream"); - } - finally - { - ArrayPool<byte>.Shared.Return(buffer); - } - } - - public async Task<object> Get(GetPlaybackInfo request) + /// <summary> + /// Gets live playback media info for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">The user id.</param> + /// <response code="200">Playback info returned.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns> + [HttpGet("Items/{itemId}/PlaybackInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute] Guid itemId, [FromQuery, Required] Guid? userId) { - var result = await GetPlaybackInfo(request.Id, request.UserId, new[] { MediaType.Audio, MediaType.Video }).ConfigureAwait(false); - return ToOptimizedResult(result); + return await GetPlaybackInfoInternal(itemId, userId).ConfigureAwait(false); } - public async Task<object> Post(OpenMediaSource request) - { - var result = await OpenMediaSource(request).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - private async Task<LiveStreamResponse> OpenMediaSource(OpenMediaSource request) + /// <summary> + /// Gets live playback media info for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">The user id.</param> + /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="maxAudioChannels">The maximum number of audio channels.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="liveStreamId">The livestream id.</param> + /// <param name="deviceProfile">The device profile.</param> + /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param> + /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> + /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> + /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param> + /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param> + /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param> + /// <response code="200">Playback info returned.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns> + [HttpPost("Items/{itemId}/PlaybackInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo( + [FromRoute] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] long? maxStreamingBitrate, + [FromQuery] long? startTimeTicks, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? mediaSourceId, + [FromQuery] string? liveStreamId, + [FromBody] DeviceProfileDto? deviceProfile, + [FromQuery] bool autoOpenLiveStream = false, + [FromQuery] bool enableDirectPlay = true, + [FromQuery] bool enableDirectStream = true, + [FromQuery] bool enableTranscoding = true, + [FromQuery] bool allowVideoStreamCopy = true, + [FromQuery] bool allowAudioStreamCopy = true) { var authInfo = _authContext.GetAuthorizationInfo(Request); - var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + var profile = deviceProfile?.DeviceProfile; + + _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile); - var profile = request.DeviceProfile; if (profile == null) { var caps = _deviceManager.GetCapabilities(authInfo.DeviceId); @@ -158,80 +153,57 @@ namespace MediaBrowser.Api.Playback } } - if (profile != null) - { - var item = _libraryManager.GetItemById(request.ItemId); - - SetDeviceSpecificData(item, result.MediaSource, profile, authInfo, request.MaxStreamingBitrate, - request.StartTimeTicks ?? 0, result.MediaSource.Id, request.AudioStreamIndex, - request.SubtitleStreamIndex, request.MaxAudioChannels, request.PlaySessionId, request.UserId, request.EnableDirectPlay, true, request.EnableDirectStream, true, true, true); - } - else - { - if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) - { - result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; - } - } + var info = await GetPlaybackInfoInternal(itemId, userId, mediaSourceId, liveStreamId).ConfigureAwait(false); - if (result.MediaSource != null) + if (profile != null) { - NormalizeMediaSourceContainer(result.MediaSource, profile, DlnaProfileType.Video); - } - - return result; - } - - public void Post(CloseMediaSource request) - { - _mediaSourceManager.CloseLiveStream(request.LiveStreamId).GetAwaiter().GetResult(); - } - - public async Task<PlaybackInfoResponse> GetPlaybackInfo(GetPostedPlaybackInfo request) - { - var authInfo = _authContext.GetAuthorizationInfo(Request); + // set device specific data + var item = _libraryManager.GetItemById(itemId); - var profile = request.DeviceProfile; - - Logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile); - - if (profile == null) - { - var caps = _deviceManager.GetCapabilities(authInfo.DeviceId); - if (caps != null) + foreach (var mediaSource in info.MediaSources) { - profile = caps.DeviceProfile; + SetDeviceSpecificData( + item, + mediaSource, + profile, + authInfo, + maxStreamingBitrate ?? profile.MaxStreamingBitrate, + startTimeTicks ?? 0, + mediaSourceId ?? string.Empty, + audioStreamIndex, + subtitleStreamIndex, + maxAudioChannels, + info!.PlaySessionId!, + userId ?? Guid.Empty, + enableDirectPlay, + enableDirectStream, + enableTranscoding, + allowVideoStreamCopy, + allowAudioStreamCopy); } - } - - var info = await GetPlaybackInfo(request.Id, request.UserId, new[] { MediaType.Audio, MediaType.Video }, request.MediaSourceId, request.LiveStreamId).ConfigureAwait(false); - - if (profile != null) - { - var mediaSourceId = request.MediaSourceId; - SetDeviceSpecificData(request.Id, info, profile, authInfo, request.MaxStreamingBitrate ?? profile.MaxStreamingBitrate, request.StartTimeTicks ?? 0, mediaSourceId, request.AudioStreamIndex, request.SubtitleStreamIndex, request.MaxAudioChannels, request.UserId, request.EnableDirectPlay, true, request.EnableDirectStream, request.EnableTranscoding, request.AllowVideoStreamCopy, request.AllowAudioStreamCopy); + SortMediaSources(info, maxStreamingBitrate); } - if (request.AutoOpenLiveStream) + if (autoOpenLiveStream) { - var mediaSource = string.IsNullOrWhiteSpace(request.MediaSourceId) ? info.MediaSources.FirstOrDefault() : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, request.MediaSourceId, StringComparison.Ordinal)); + var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal)); if (mediaSource != null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId)) { - var openStreamResult = await OpenMediaSource(new OpenMediaSource + var openStreamResult = await OpenMediaSource(new LiveStreamRequest { - AudioStreamIndex = request.AudioStreamIndex, - DeviceProfile = request.DeviceProfile, - EnableDirectPlay = request.EnableDirectPlay, - EnableDirectStream = request.EnableDirectStream, - ItemId = request.Id, - MaxAudioChannels = request.MaxAudioChannels, - MaxStreamingBitrate = request.MaxStreamingBitrate, + AudioStreamIndex = audioStreamIndex, + DeviceProfile = deviceProfile?.DeviceProfile, + EnableDirectPlay = enableDirectPlay, + EnableDirectStream = enableDirectStream, + ItemId = itemId, + MaxAudioChannels = maxAudioChannels, + MaxStreamingBitrate = maxStreamingBitrate, PlaySessionId = info.PlaySessionId, - StartTimeTicks = request.StartTimeTicks, - SubtitleStreamIndex = request.SubtitleStreamIndex, - UserId = request.UserId, + StartTimeTicks = startTimeTicks, + SubtitleStreamIndex = subtitleStreamIndex, + UserId = userId ?? Guid.Empty, OpenToken = mediaSource.OpenToken }).ConfigureAwait(false); @@ -243,36 +215,132 @@ namespace MediaBrowser.Api.Playback { foreach (var mediaSource in info.MediaSources) { - NormalizeMediaSourceContainer(mediaSource, profile, DlnaProfileType.Video); + NormalizeMediaSourceContainer(mediaSource, profile!, DlnaProfileType.Video); } } return info; } - private void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) + /// <summary> + /// Opens a media source. + /// </summary> + /// <param name="openToken">The open token.</param> + /// <param name="userId">The user id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="maxAudioChannels">The maximum number of audio channels.</param> + /// <param name="itemId">The item id.</param> + /// <param name="openLiveStreamDto">The open live stream dto.</param> + /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> + /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> + /// <response code="200">Media source opened.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns> + [HttpPost("LiveStreams/Open")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream( + [FromQuery] string? openToken, + [FromQuery] Guid? userId, + [FromQuery] string? playSessionId, + [FromQuery] long? maxStreamingBitrate, + [FromQuery] long? startTimeTicks, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] int? maxAudioChannels, + [FromQuery] Guid? itemId, + [FromBody] OpenLiveStreamDto openLiveStreamDto, + [FromQuery] bool enableDirectPlay = true, + [FromQuery] bool enableDirectStream = true) { - mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type); + var request = new LiveStreamRequest + { + OpenToken = openToken, + UserId = userId ?? Guid.Empty, + PlaySessionId = playSessionId, + MaxStreamingBitrate = maxStreamingBitrate, + StartTimeTicks = startTimeTicks, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + MaxAudioChannels = maxAudioChannels, + ItemId = itemId ?? Guid.Empty, + DeviceProfile = openLiveStreamDto?.DeviceProfile, + EnableDirectPlay = enableDirectPlay, + EnableDirectStream = enableDirectStream, + DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http } + }; + return await OpenMediaSource(request).ConfigureAwait(false); + } + + /// <summary> + /// Closes a media source. + /// </summary> + /// <param name="liveStreamId">The livestream id.</param> + /// <response code="204">Livestream closed.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("LiveStreams/Close")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CloseLiveStream([FromQuery, Required] string? liveStreamId) + { + _mediaSourceManager.CloseLiveStream(liveStreamId).GetAwaiter().GetResult(); + return NoContent(); } - public async Task<object> Post(GetPostedPlaybackInfo request) + /// <summary> + /// Tests the network with a request with the size of the bitrate. + /// </summary> + /// <param name="size">The bitrate. Defaults to 102400.</param> + /// <response code="200">Test buffer returned.</response> + /// <response code="400">Size has to be a numer between 0 and 10,000,000.</response> + /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns> + [HttpGet("Playback/BitrateTest")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Produces(MediaTypeNames.Application.Octet)] + public ActionResult GetBitrateTestBytes([FromQuery] int size = 102400) { - var result = await GetPlaybackInfo(request).ConfigureAwait(false); + const int MaxSize = 10_000_000; + + if (size <= 0) + { + return BadRequest($"The requested size ({size}) is equal to or smaller than 0."); + } - return ToOptimizedResult(result); + if (size > MaxSize) + { + return BadRequest($"The requested size ({size}) is larger than the max allowed value ({MaxSize})."); + } + + byte[] buffer = ArrayPool<byte>.Shared.Rent(size); + try + { + new Random().NextBytes(buffer); + return File(buffer, MediaTypeNames.Application.Octet); + } + finally + { + ArrayPool<byte>.Shared.Return(buffer); + } } - private async Task<PlaybackInfoResponse> GetPlaybackInfo(Guid id, Guid userId, string[] supportedLiveMediaTypes, string mediaSourceId = null, string liveStreamId = null) + private async Task<PlaybackInfoResponse> GetPlaybackInfoInternal( + Guid id, + Guid? userId, + string? mediaSourceId = null, + string? liveStreamId = null) { - var user = _userManager.GetUserById(userId); + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; var item = _libraryManager.GetItemById(id); var result = new PlaybackInfoResponse(); MediaSourceInfo[] mediaSources; if (string.IsNullOrWhiteSpace(liveStreamId)) { - - // TODO handle supportedLiveMediaTypes? + // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(mediaSourceId)) @@ -297,10 +365,7 @@ namespace MediaBrowser.Api.Playback { result.MediaSources = Array.Empty<MediaSourceInfo>(); - if (!result.ErrorCode.HasValue) - { - result.ErrorCode = PlaybackErrorCode.NoCompatibleStream; - } + result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream; } else { @@ -314,33 +379,9 @@ namespace MediaBrowser.Api.Playback return result; } - private void SetDeviceSpecificData( - Guid itemId, - PlaybackInfoResponse result, - DeviceProfile profile, - AuthorizationInfo auth, - long? maxBitrate, - long startTimeTicks, - string mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - int? maxAudioChannels, - Guid userId, - bool enableDirectPlay, - bool forceDirectPlayRemoteMediaSource, - bool enableDirectStream, - bool enableTranscoding, - bool allowVideoStreamCopy, - bool allowAudioStreamCopy) + private void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) { - var item = _libraryManager.GetItemById(itemId); - - foreach (var mediaSource in result.MediaSources) - { - SetDeviceSpecificData(item, mediaSource, profile, auth, maxBitrate, startTimeTicks, mediaSourceId, audioStreamIndex, subtitleStreamIndex, maxAudioChannels, result.PlaySessionId, userId, enableDirectPlay, forceDirectPlayRemoteMediaSource, enableDirectStream, enableTranscoding, allowVideoStreamCopy, allowAudioStreamCopy); - } - - SortMediaSources(result, maxBitrate); + mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type); } private void SetDeviceSpecificData( @@ -357,13 +398,12 @@ namespace MediaBrowser.Api.Playback string playSessionId, Guid userId, bool enableDirectPlay, - bool forceDirectPlayRemoteMediaSource, bool enableDirectStream, bool enableTranscoding, bool allowVideoStreamCopy, bool allowAudioStreamCopy) { - var streamBuilder = new StreamBuilder(_mediaEncoder, Logger); + var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); var options = new VideoOptions { @@ -401,14 +441,15 @@ namespace MediaBrowser.Api.Playback if (item is Audio) { - Logger.LogInformation( + _logger.LogInformation( "User policy for {0}. EnableAudioPlaybackTranscoding: {1}", user.Username, user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); } else { - Logger.LogInformation("User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", + _logger.LogInformation( + "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", user.Username, user.HasPermission(PermissionKind.EnablePlaybackRemuxing), user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), @@ -586,28 +627,57 @@ namespace MediaBrowser.Api.Playback } } - private long? GetMaxBitrate(long? clientMaxBitrate, Jellyfin.Data.Entities.User user) + private async Task<LiveStreamResponse> OpenMediaSource(LiveStreamRequest request) { - var maxBitrate = clientMaxBitrate; - var remoteClientMaxBitrate = user?.RemoteClientBitrateLimit ?? 0; + var authInfo = _authContext.GetAuthorizationInfo(Request); - if (remoteClientMaxBitrate <= 0) + var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + + var profile = request.DeviceProfile; + if (profile == null) { - remoteClientMaxBitrate = ServerConfigurationManager.Configuration.RemoteClientBitrateLimit; + var caps = _deviceManager.GetCapabilities(authInfo.DeviceId); + if (caps != null) + { + profile = caps.DeviceProfile; + } } - if (remoteClientMaxBitrate > 0) + if (profile != null) { - var isInLocalNetwork = _networkManager.IsInLocalNetwork(Request.RemoteIp); + var item = _libraryManager.GetItemById(request.ItemId); - Logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, Request.RemoteIp, isInLocalNetwork); - if (!isInLocalNetwork) + SetDeviceSpecificData( + item, + result.MediaSource, + profile, + authInfo, + request.MaxStreamingBitrate, + request.StartTimeTicks ?? 0, + result.MediaSource.Id, + request.AudioStreamIndex, + request.SubtitleStreamIndex, + request.MaxAudioChannels, + request.PlaySessionId, + request.UserId, + request.EnableDirectPlay, + request.EnableDirectStream, + true, + true, + true); + } + else + { + if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) { - maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); + result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; } } - return maxBitrate; + // here was a check if (result.MediaSource != null) but Rider said it will never be null + NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video); + + return result; } private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) @@ -635,45 +705,73 @@ namespace MediaBrowser.Api.Playback } } + private long? GetMaxBitrate(long? clientMaxBitrate, User user) + { + var maxBitrate = clientMaxBitrate; + var remoteClientMaxBitrate = user?.RemoteClientBitrateLimit ?? 0; + + if (remoteClientMaxBitrate <= 0) + { + remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit; + } + + if (remoteClientMaxBitrate > 0) + { + var isInLocalNetwork = _networkManager.IsInLocalNetwork(Request.HttpContext.Connection.RemoteIpAddress.ToString()); + + _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, Request.HttpContext.Connection.RemoteIpAddress.ToString(), isInLocalNetwork); + if (!isInLocalNetwork) + { + maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); + } + } + + return maxBitrate; + } + private void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) { var originalList = result.MediaSources.ToList(); result.MediaSources = result.MediaSources.OrderBy(i => - { - // Nothing beats direct playing a file - if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) { - return 0; - } + // Nothing beats direct playing a file + if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) + { + return 0; + } - return 1; - }).ThenBy(i => - { - // Let's assume direct streaming a file is just as desirable as direct playing a remote url - if (i.SupportsDirectPlay || i.SupportsDirectStream) + return 1; + }) + .ThenBy(i => { - return 0; - } + // Let's assume direct streaming a file is just as desirable as direct playing a remote url + if (i.SupportsDirectPlay || i.SupportsDirectStream) + { + return 0; + } - return 1; - }).ThenBy(i => - { - return i.Protocol switch + return 1; + }) + .ThenBy(i => { - MediaProtocol.File => 0, - _ => 1, - }; - }).ThenBy(i => - { - if (maxBitrate.HasValue && i.Bitrate.HasValue) + return i.Protocol switch + { + MediaProtocol.File => 0, + _ => 1, + }; + }) + .ThenBy(i => { - return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; - } + if (maxBitrate.HasValue && i.Bitrate.HasValue) + { + return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; + } - return 1; - }).ThenBy(originalList.IndexOf) - .ToArray(); + return 1; + }) + .ThenBy(originalList.IndexOf) + .ToArray(); } } } diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs new file mode 100644 index 000000000..148d8a18e --- /dev/null +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Movies controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class MoviesController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MoviesController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public MoviesController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService, + IServerConfigurationManager serverConfigurationManager) + { + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <summary> + /// Gets movie recommendations. + /// </summary> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. The fields to return.</param> + /// <param name="categoryLimit">The max number of categories to return.</param> + /// <param name="itemLimit">The max number of items to return per category.</param> + /// <response code="200">Movie recommendations returned.</response> + /// <returns>The list of movie recommendations.</returns> + [HttpGet("Recommendations")] + public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( + [FromQuery] Guid? userId, + [FromQuery] string? parentId, + [FromQuery] string? 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) + .AddClientFields(Request); + + var categories = new List<RecommendationDto>(); + + var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); + + var query = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] + { + nameof(Movie), + // typeof(Trailer).Name, + // typeof(LiveTvProgram).Name + }, + // IsMovie = true + OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), + Limit = 7, + ParentId = parentIdGuid, + Recursive = true, + IsPlayed = true, + DtoOptions = dtoOptions + }; + + var recentlyPlayedMovies = _libraryManager.GetItemList(query); + + var itemTypes = new List<string> { nameof(Movie) }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(nameof(Trailer)); + itemTypes.Add(nameof(LiveTvProgram)); + } + + var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), + Limit = 10, + IsFavoriteOrLiked = true, + ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), + EnableGroupByMetadataKey = true, + ParentId = parentIdGuid, + Recursive = true, + DtoOptions = dtoOptions + }); + + var mostRecentMovies = recentlyPlayedMovies.Take(6).ToList(); + // Get recently played directors + var recentDirectors = GetDirectors(mostRecentMovies) + .ToList(); + + // Get recently played actors + var recentActors = GetActors(mostRecentMovies) + .ToList(); + + var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); + var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); + + var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); + var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); + + var categoryTypes = new List<IEnumerator<RecommendationDto>> + { + // Give this extra weight + similarToRecentlyPlayed, + similarToRecentlyPlayed, + + // Give this extra weight + similarToLiked, + similarToLiked, + hasDirectorFromRecentlyPlayed, + hasActorFromRecentlyPlayed + }; + + while (categories.Count < categoryLimit) + { + var allEmpty = true; + + foreach (var category in categoryTypes) + { + if (category.MoveNext()) + { + categories.Add(category.Current); + allEmpty = false; + + if (categories.Count >= categoryLimit) + { + break; + } + } + } + + if (allEmpty) + { + break; + } + } + + return Ok(categories.OrderBy(i => i.RecommendationType)); + } + + private IEnumerable<RecommendationDto> GetWithDirector( + User? user, + IEnumerable<string> names, + int itemLimit, + DtoOptions dtoOptions, + RecommendationType type) + { + var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(nameof(Trailer)); + itemTypes.Add(nameof(LiveTvProgram)); + } + + foreach (var name in names) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Person = name, + // Account for duplicates by imdb id, since the database doesn't support this yet + Limit = itemLimit + 2, + PersonTypes = new[] { PersonType.Director }, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + .Select(x => x.First()) + .Take(itemLimit) + .ToList(); + + if (items.Count > 0) + { + var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); + + yield return new RecommendationDto + { + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = returnItems + }; + } + } + } + + private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + { + var itemTypes = new List<string> { nameof(Movie) }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(nameof(Trailer)); + itemTypes.Add(nameof(LiveTvProgram)); + } + + foreach (var name in names) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Person = name, + // Account for duplicates by imdb id, since the database doesn't support this yet + Limit = itemLimit + 2, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + .Select(x => x.First()) + .Take(itemLimit) + .ToList(); + + if (items.Count > 0) + { + var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); + + yield return new RecommendationDto + { + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = returnItems + }; + } + } + } + + private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + { + var itemTypes = new List<string> { nameof(Movie) }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(nameof(Trailer)); + itemTypes.Add(nameof(LiveTvProgram)); + } + + foreach (var item in baselineItems) + { + var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Limit = itemLimit, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + SimilarTo = item, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }); + + if (similar.Count > 0) + { + var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); + + yield return new RecommendationDto + { + BaselineItemName = item.Name, + CategoryId = item.Id, + RecommendationType = type, + Items = returnItems + }; + } + } + } + + private IEnumerable<string> GetActors(IEnumerable<BaseItem> items) + { + var people = _libraryManager.GetPeople(new InternalPeopleQuery + { + ExcludePersonTypes = new[] { PersonType.Director }, + MaxListOrder = 3 + }); + + var itemIds = items.Select(i => i.Id).ToList(); + + return people + .Where(i => itemIds.Contains(i.ItemId)) + .Select(i => i.Name) + .DistinctNames(); + } + + private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items) + { + var people = _libraryManager.GetPeople(new InternalPeopleQuery + { + PersonTypes = new[] { PersonType.Director } + }); + + var itemIds = items.Select(i => i.Id).ToList(); + + return people + .Where(i => itemIds.Contains(i.ItemId)) + .Select(i => i.Name) + .DistinctNames(); + } + } +} diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs new file mode 100644 index 000000000..0d319137a --- /dev/null +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -0,0 +1,313 @@ +using System; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +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.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The music genres controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class MusicGenresController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicGenresController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> + public MusicGenresController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } + + /// <summary> + /// Gets all music genres from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> + /// <response code="200">Music genres returned.</response> + /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns> + [HttpGet] + public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres( + [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] string? 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] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions() + .AddItemFields(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) + { + 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 RequestHelpers.GetFilters(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<BaseItemDto> + { + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; + } + + /// <summary> + /// Gets a music genre, by name. + /// </summary> + /// <param name="genreName">The genre name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns> + [HttpGet("{genreName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetMusicGenre([FromRoute] string genreName, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions().AddClientFields(Request); + + MusicGenre item; + + if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) + { + item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions); + } + else + { + item = _libraryManager.GetMusicGenre(genreName); + } + + if (userId.HasValue && !userId.Equals(Guid.Empty)) + { + var user = _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + return _dtoService.GetBaseItemDto(item, dtoOptions); + } + + private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) + where T : BaseItem, new() + { + var result = libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '&'), + IncludeItemTypes = new[] { typeof(T).Name }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); + + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '/'), + IncludeItemTypes = new[] { typeof(T).Name }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); + + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '?'), + IncludeItemTypes = new[] { typeof(T).Name }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); + + return result; + } + } +} diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs new file mode 100644 index 000000000..cf3e780b4 --- /dev/null +++ b/Jellyfin.Api/Controllers/NotificationsController.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.NotificationDtos; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Notifications; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Notifications; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The notification controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class NotificationsController : BaseJellyfinApiController + { + private readonly INotificationManager _notificationManager; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="NotificationsController" /> class. + /// </summary> + /// <param name="notificationManager">The notification manager.</param> + /// <param name="userManager">The user manager.</param> + public NotificationsController(INotificationManager notificationManager, IUserManager userManager) + { + _notificationManager = notificationManager; + _userManager = userManager; + } + + /// <summary> + /// Gets a user's notifications. + /// </summary> + /// <response code="200">Notifications returned.</response> + /// <returns>An <see cref="OkResult"/> containing a list of notifications.</returns> + [HttpGet("{userId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<NotificationResultDto> GetNotifications() + { + return new NotificationResultDto(); + } + + /// <summary> + /// Gets a user's notification summary. + /// </summary> + /// <response code="200">Summary of user's notifications returned.</response> + /// <returns>An <cref see="OkResult"/> containing a summary of the users notifications.</returns> + [HttpGet("{userId}/Summary")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<NotificationsSummaryDto> GetNotificationsSummary() + { + return new NotificationsSummaryDto(); + } + + /// <summary> + /// Gets notification types. + /// </summary> + /// <response code="200">All notification types returned.</response> + /// <returns>An <cref see="OkResult"/> containing a list of all notification types.</returns> + [HttpGet("Types")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<NotificationTypeInfo> GetNotificationTypes() + { + return _notificationManager.GetNotificationTypes(); + } + + /// <summary> + /// Gets notification services. + /// </summary> + /// <response code="200">All notification services returned.</response> + /// <returns>An <cref see="OkResult"/> containing a list of all notification services.</returns> + [HttpGet("Services")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<NameIdPair> GetNotificationServices() + { + return _notificationManager.GetNotificationServices(); + } + + /// <summary> + /// Sends a notification to all admins. + /// </summary> + /// <param name="url">The URL of the notification.</param> + /// <param name="level">The level of the notification.</param> + /// <param name="name">The name of the notification.</param> + /// <param name="description">The description of the notification.</param> + /// <response code="204">Notification sent.</response> + /// <returns>A <cref see="NoContentResult"/>.</returns> + [HttpPost("Admin")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CreateAdminNotification( + [FromQuery] string? url, + [FromQuery] NotificationLevel? level, + [FromQuery] string name = "", + [FromQuery] string description = "") + { + var notification = new NotificationRequest + { + Name = name, + Description = description, + Url = url, + Level = level ?? NotificationLevel.Normal, + UserIds = _userManager.Users + .Where(user => user.HasPermission(PermissionKind.IsAdministrator)) + .Select(user => user.Id) + .ToArray(), + Date = DateTime.UtcNow, + }; + + _notificationManager.SendNotification(notification, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Sets notifications as read. + /// </summary> + /// <response code="204">Notifications set as read.</response> + /// <returns>A <cref see="NoContentResult"/>.</returns> + [HttpPost("{userId}/Read")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRead() + { + return NoContent(); + } + + /// <summary> + /// Sets notifications as unread. + /// </summary> + /// <response code="204">Notifications set as unread.</response> + /// <returns>A <cref see="NoContentResult"/>.</returns> + [HttpPost("{userId}/Unread")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetUnread() + { + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs new file mode 100644 index 000000000..3d6a87909 --- /dev/null +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Updates; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Updates; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Package Controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class PackageController : BaseJellyfinApiController + { + private readonly IInstallationManager _installationManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="PackageController"/> class. + /// </summary> + /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) + { + _installationManager = installationManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <summary> + /// Gets a package by name or assembly GUID. + /// </summary> + /// <param name="name">The name of the package.</param> + /// <param name="assemblyGuid">The GUID of the associated assembly.</param> + /// <response code="200">Package retrieved.</response> + /// <returns>A <see cref="PackageInfo"/> containing package information.</returns> + [HttpGet("Packages/{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PackageInfo>> GetPackageInfo( + [FromRoute] [Required] string? name, + [FromQuery] string? assemblyGuid) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + var result = _installationManager.FilterPackages( + packages, + name, + string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid)).FirstOrDefault(); + + return result; + } + + /// <summary> + /// Gets available packages. + /// </summary> + /// <response code="200">Available packages returned.</response> + /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns> + [HttpGet("Packages")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<IEnumerable<PackageInfo>> GetPackages() + { + IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + + return packages; + } + + /// <summary> + /// Installs a package. + /// </summary> + /// <param name="name">Package name.</param> + /// <param name="assemblyGuid">GUID of the associated assembly.</param> + /// <param name="version">Optional version. Defaults to latest version.</param> + /// <response code="204">Package found.</response> + /// <response code="404">Package not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns> + [HttpPost("Packages/Installed/{name}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult> InstallPackage( + [FromRoute] [Required] string? name, + [FromQuery] string? assemblyGuid, + [FromQuery] string? version) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + var package = _installationManager.GetCompatibleVersions( + packages, + name, + string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid), + string.IsNullOrEmpty(version) ? null : Version.Parse(version)).FirstOrDefault(); + + if (package == null) + { + return NotFound(); + } + + await _installationManager.InstallPackage(package).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Cancels a package installation. + /// </summary> + /// <param name="packageId">Installation Id.</param> + /// <response code="204">Installation cancelled.</response> + /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns> + [HttpDelete("Packages/Installing/{packageId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CancelPackageInstallation( + [FromRoute] [Required] Guid packageId) + { + _installationManager.CancelInstallation(packageId); + return NoContent(); + } + + /// <summary> + /// Gets all package repositories. + /// </summary> + /// <response code="200">Package repositories returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns> + [HttpGet("Repositories")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories() + { + return _serverConfigurationManager.Configuration.PluginRepositories; + } + + /// <summary> + /// Sets the enabled and existing package repositories. + /// </summary> + /// <param name="repositoryInfos">The list of package repositories.</param> + /// <response code="204">Package repositories saved.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpOptions("Repositories")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRepositories([FromBody] List<RepositoryInfo> repositoryInfos) + { + _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos; + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs new file mode 100644 index 000000000..b6ccec666 --- /dev/null +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -0,0 +1,285 @@ +using System; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Dto; +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; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Persons controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class PersonsController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="PersonsController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public PersonsController( + ILibraryManager libraryManager, + IDtoService dtoService, + IUserManager userManager) + { + _libraryManager = libraryManager; + _dtoService = dtoService; + _userManager = userManager; + } + + /// <summary> + /// Gets all persons from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> + /// <response code="200">Persons returned.</response> + /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> 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] string? 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] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions() + .AddItemFields(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) + { + 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 RequestHelpers.GetFilters(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<BaseItemDto> + { + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; + } + + /// <summary> + /// Get person by name. + /// </summary> + /// <param name="name">Person name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Person returned.</response> + /// <response code="404">Person not found.</response> + /// <returns>An <see cref="OkResult"/> containing the person on success, + /// or a <see cref="NotFoundResult"/> if person not found.</returns> + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<BaseItemDto> GetPerson([FromRoute] string name, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions() + .AddClientFields(Request); + + var item = _libraryManager.GetPerson(name); + if (item == null) + { + return NotFound(); + } + + if (userId.HasValue && !userId.Equals(Guid.Empty)) + { + var user = _userManager.GetUserById(userId.Value); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + return _dtoService.GetBaseItemDto(item, dtoOptions); + } + } +} diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs new file mode 100644 index 000000000..12c87d7c3 --- /dev/null +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -0,0 +1,199 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.PlaylistDtos; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Playlists; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Playlists controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class PlaylistsController : BaseJellyfinApiController + { + private readonly IPlaylistManager _playlistManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="PlaylistsController"/> class. + /// </summary> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public PlaylistsController( + IDtoService dtoService, + IPlaylistManager playlistManager, + IUserManager userManager, + ILibraryManager libraryManager) + { + _dtoService = dtoService; + _playlistManager = playlistManager; + _userManager = userManager; + _libraryManager = libraryManager; + } + + /// <summary> + /// Creates a new playlist. + /// </summary> + /// <param name="createPlaylistRequest">The create playlist payload.</param> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist. + /// The task result contains an <see cref="OkResult"/> indicating success. + /// </returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( + [FromBody, Required] CreatePlaylistDto createPlaylistRequest) + { + Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids); + var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest + { + Name = createPlaylistRequest.Name, + ItemIdList = idGuidArray, + UserId = createPlaylistRequest.UserId, + MediaType = createPlaylistRequest.MediaType + }).ConfigureAwait(false); + + return result; + } + + /// <summary> + /// Adds items to a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="ids">Item id, comma delimited.</param> + /// <param name="userId">The userId.</param> + /// <response code="204">Items added to playlist.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddToPlaylist( + [FromRoute] string? playlistId, + [FromQuery] string? ids, + [FromQuery] Guid? userId) + { + _playlistManager.AddToPlaylist(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty); + return NoContent(); + } + + /// <summary> + /// Moves a playlist item. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="newIndex">The new index.</param> + /// <response code="204">Item moved to new index.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult MoveItem( + [FromRoute] string? playlistId, + [FromRoute] string? itemId, + [FromRoute] int newIndex) + { + _playlistManager.MoveItem(playlistId, itemId, newIndex); + return NoContent(); + } + + /// <summary> + /// Removes items from a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="entryIds">The item ids, comma delimited.</param> + /// <response code="204">Items removed.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpDelete("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveFromPlaylist([FromRoute] string? playlistId, [FromQuery] string? entryIds) + { + _playlistManager.RemoveFromPlaylist(playlistId, RequestHelpers.Split(entryIds, ',', true)); + return NoContent(); + } + + /// <summary> + /// Gets the original items of a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="userId">User id.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Original playlist returned.</response> + /// <response code="404">Playlist not found.</response> + /// <returns>The original playlist items.</returns> + [HttpGet("{playlistId}/Items")] + public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems( + [FromRoute] Guid playlistId, + [FromRoute] Guid userId, + [FromRoute] int? startIndex, + [FromRoute] int? limit, + [FromRoute] string? fields, + [FromRoute] bool? enableImages, + [FromRoute] bool? enableUserData, + [FromRoute] int? imageTypeLimit, + [FromRoute] string? enableImageTypes) + { + var playlist = (Playlist)_libraryManager.GetItemById(playlistId); + if (playlist == null) + { + return NotFound(); + } + + var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null; + + var items = playlist.GetManageableItems().ToArray(); + + var count = items.Length; + + if (startIndex.HasValue) + { + items = items.Skip(startIndex.Value).ToArray(); + } + + if (limit.HasValue) + { + items = items.Take(limit.Value).ToArray(); + } + + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); + + for (int index = 0; index < dtos.Count; index++) + { + dtos[index].PlaylistItemId = items[index].Item1.Id; + } + + var result = new QueryResult<BaseItemDto> + { + Items = dtos, + TotalRecordCount = count + }; + + return result; + } + } +} diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs new file mode 100644 index 000000000..0422bfe72 --- /dev/null +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -0,0 +1,367 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Playstate controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class PlaystateController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataRepository; + private readonly ILibraryManager _libraryManager; + private readonly ISessionManager _sessionManager; + private readonly IAuthorizationContext _authContext; + private readonly ILogger<PlaystateController> _logger; + private readonly TranscodingJobHelper _transcodingJobHelper; + + /// <summary> + /// Initializes a new instance of the <see cref="PlaystateController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + /// <param name="transcodingJobHelper">Th <see cref="TranscodingJobHelper"/> singleton.</param> + public PlaystateController( + IUserManager userManager, + IUserDataManager userDataRepository, + ILibraryManager libraryManager, + ISessionManager sessionManager, + IAuthorizationContext authContext, + ILoggerFactory loggerFactory, + TranscodingJobHelper transcodingJobHelper) + { + _userManager = userManager; + _userDataRepository = userDataRepository; + _libraryManager = libraryManager; + _sessionManager = sessionManager; + _authContext = authContext; + _logger = loggerFactory.CreateLogger<PlaystateController>(); + + _transcodingJobHelper = transcodingJobHelper; + } + + /// <summary> + /// Marks an item as played for user. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="datePlayed">Optional. The date the item was played.</param> + /// <response code="200">Item marked as played.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpPost("Users/{userId}/PlayedItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> MarkPlayedItem( + [FromRoute] Guid userId, + [FromRoute] Guid itemId, + [FromQuery] DateTime? datePlayed) + { + var user = _userManager.GetUserById(userId); + var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + var dto = UpdatePlayedStatus(user, itemId, true, datePlayed); + foreach (var additionalUserInfo in session.AdditionalUsers) + { + var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); + UpdatePlayedStatus(additionalUser, itemId, true, datePlayed); + } + + return dto; + } + + /// <summary> + /// Marks an item as unplayed for user. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item marked as unplayed.</response> + /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpDelete("Users/{userId}/PlayedItem/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> MarkUnplayedItem([FromRoute] Guid userId, [FromRoute] Guid itemId) + { + var user = _userManager.GetUserById(userId); + var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + var dto = UpdatePlayedStatus(user, itemId, false, null); + foreach (var additionalUserInfo in session.AdditionalUsers) + { + var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); + UpdatePlayedStatus(additionalUser, itemId, false, null); + } + + return dto; + } + + /// <summary> + /// Reports playback has started within a session. + /// </summary> + /// <param name="playbackStartInfo">The playback start info.</param> + /// <response code="204">Playback start recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo) + { + playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); + playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Reports playback progress within a session. + /// </summary> + /// <param name="playbackProgressInfo">The playback progress info.</param> + /// <response code="204">Playback progress recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing/Progress")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo) + { + playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); + playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Pings a playback session. + /// </summary> + /// <param name="playSessionId">Playback session id.</param> + /// <response code="204">Playback session pinged.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing/Ping")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PingPlaybackSession([FromQuery] string playSessionId) + { + _transcodingJobHelper.PingTranscodingJob(playSessionId, null); + return NoContent(); + } + + /// <summary> + /// Reports playback has stopped within a session. + /// </summary> + /// <param name="playbackStopInfo">The playback stop info.</param> + /// <response code="204">Playback stop recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing/Stopped")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo) + { + _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); + if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) + { + await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + } + + playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Reports that a user has begun playing an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="mediaSourceId">The id of the MediaSource.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="playMethod">The play method.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="canSeek">Indicates if the client can seek.</param> + /// <response code="204">Play start recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/PlayingItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task<ActionResult> OnPlaybackStart( + [FromRoute] Guid userId, + [FromRoute] Guid itemId, + [FromQuery] string? mediaSourceId, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] PlayMethod playMethod, + [FromQuery] string? liveStreamId, + [FromQuery] string playSessionId, + [FromQuery] bool canSeek = false) + { + var playbackStartInfo = new PlaybackStartInfo + { + CanSeek = canSeek, + ItemId = itemId, + MediaSourceId = mediaSourceId, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + PlayMethod = playMethod, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId + }; + + playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); + playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Reports a user's playback progress. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="mediaSourceId">The id of the MediaSource.</param> + /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="volumeLevel">Scale of 0-100.</param> + /// <param name="playMethod">The play method.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="repeatMode">The repeat mode.</param> + /// <param name="isPaused">Indicates if the player is paused.</param> + /// <param name="isMuted">Indicates if the player is muted.</param> + /// <response code="204">Play progress recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task<ActionResult> OnPlaybackProgress( + [FromRoute] Guid userId, + [FromRoute] Guid itemId, + [FromQuery] string? mediaSourceId, + [FromQuery] long? positionTicks, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] int? volumeLevel, + [FromQuery] PlayMethod playMethod, + [FromQuery] string? liveStreamId, + [FromQuery] string playSessionId, + [FromQuery] RepeatMode repeatMode, + [FromQuery] bool isPaused = false, + [FromQuery] bool isMuted = false) + { + var playbackProgressInfo = new PlaybackProgressInfo + { + ItemId = itemId, + PositionTicks = positionTicks, + IsMuted = isMuted, + IsPaused = isPaused, + MediaSourceId = mediaSourceId, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + VolumeLevel = volumeLevel, + PlayMethod = playMethod, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + RepeatMode = repeatMode + }; + + playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); + playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Reports that a user has stopped playing an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="mediaSourceId">The id of the MediaSource.</param> + /// <param name="nextMediaType">The next media type that will play.</param> + /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <response code="204">Playback stop recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Users/{userId}/PlayingItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task<ActionResult> OnPlaybackStopped( + [FromRoute] Guid userId, + [FromRoute] Guid itemId, + [FromQuery] string? mediaSourceId, + [FromQuery] string? nextMediaType, + [FromQuery] long? positionTicks, + [FromQuery] string? liveStreamId, + [FromQuery] string? playSessionId) + { + var playbackStopInfo = new PlaybackStopInfo + { + ItemId = itemId, + PositionTicks = positionTicks, + MediaSourceId = mediaSourceId, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + NextMediaType = nextMediaType + }; + + _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); + if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) + { + await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + } + + playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Updates the played status. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="itemId">The item id.</param> + /// <param name="wasPlayed">if set to <c>true</c> [was played].</param> + /// <param name="datePlayed">The date played.</param> + /// <returns>Task.</returns> + private UserItemDataDto UpdatePlayedStatus(User user, Guid itemId, bool wasPlayed, DateTime? datePlayed) + { + var item = _libraryManager.GetItemById(itemId); + + if (wasPlayed) + { + item.MarkPlayed(user, datePlayed, true); + } + else + { + item.MarkUnplayed(user); + } + + return _userDataRepository.GetUserDataDto(item, user); + } + + private PlayMethod ValidatePlayMethod(PlayMethod method, string playSessionId) + { + if (method == PlayMethod.Transcode) + { + var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId); + if (job == null) + { + return PlayMethod.DirectPlay; + } + } + + return method; + } + } +} diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs new file mode 100644 index 000000000..b2f34680b --- /dev/null +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.PluginDtos; +using MediaBrowser.Common; +using MediaBrowser.Common.Json; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Common.Updates; +using MediaBrowser.Model.Plugins; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Plugins controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class PluginsController : BaseJellyfinApiController + { + private readonly IApplicationHost _appHost; + private readonly IInstallationManager _installationManager; + + private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.GetOptions(); + + /// <summary> + /// Initializes a new instance of the <see cref="PluginsController"/> class. + /// </summary> + /// <param name="appHost">Instance of the <see cref="IApplicationHost"/> interface.</param> + /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> + public PluginsController( + IApplicationHost appHost, + IInstallationManager installationManager) + { + _appHost = appHost; + _installationManager = installationManager; + } + + /// <summary> + /// Gets a list of currently installed plugins. + /// </summary> + /// <response code="200">Installed plugins returned.</response> + /// <returns>List of currently installed plugins.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<PluginInfo>> GetPlugins() + { + return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo())); + } + + /// <summary> + /// Uninstalls a plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin uninstalled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpDelete("{pluginId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UninstallPlugin([FromRoute] Guid pluginId) + { + var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId); + if (plugin == null) + { + return NotFound(); + } + + _installationManager.UninstallPlugin(plugin); + return NoContent(); + } + + /// <summary> + /// Gets plugin configuration. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="200">Plugin configuration returned.</response> + /// <response code="404">Plugin not found or plugin configuration not found.</response> + /// <returns>Plugin configuration.</returns> + [HttpGet("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute] Guid pluginId) + { + if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin)) + { + return NotFound(); + } + + return plugin.Configuration; + } + + /// <summary> + /// Updates plugin configuration. + /// </summary> + /// <remarks> + /// Accepts plugin configuration as JSON body. + /// </remarks> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin configuration updated.</response> + /// <response code="404">Plugin not found or plugin does not have configuration.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to update plugin configuration. + /// The task result contains an <see cref="NoContentResult"/> indicating success, or <see cref="NotFoundResult"/> + /// when plugin not found or plugin doesn't have configuration. + /// </returns> + [HttpPost("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdatePluginConfiguration([FromRoute] Guid pluginId) + { + if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin)) + { + return NotFound(); + } + + var configuration = (BasePluginConfiguration)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType, _serializerOptions) + .ConfigureAwait(false); + + plugin.UpdateConfiguration(configuration); + return NoContent(); + } + + /// <summary> + /// Get plugin security info. + /// </summary> + /// <response code="200">Plugin security info returned.</response> + /// <returns>Plugin security info.</returns> + [Obsolete("This endpoint should not be used.")] + [HttpGet("SecurityInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo() + { + return new PluginSecurityInfo + { + IsMbSupporter = true, + SupporterKey = "IAmTotallyLegit" + }; + } + + /// <summary> + /// Updates plugin security info. + /// </summary> + /// <param name="pluginSecurityInfo">Plugin security info.</param> + /// <response code="204">Plugin security info updated.</response> + /// <returns>An <see cref="NoContentResult"/>.</returns> + [Obsolete("This endpoint should not be used.")] + [HttpPost("SecurityInfo")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdatePluginSecurityInfo([FromBody, Required] PluginSecurityInfo pluginSecurityInfo) + { + return NoContent(); + } + + /// <summary> + /// Gets registration status for a feature. + /// </summary> + /// <param name="name">Feature name.</param> + /// <response code="200">Registration status returned.</response> + /// <returns>Mb registration record.</returns> + [Obsolete("This endpoint should not be used.")] + [HttpPost("RegistrationRecords/{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string? name) + { + return new MBRegistrationRecord + { + IsRegistered = true, + RegChecked = true, + TrialVersion = false, + IsValid = true, + RegError = false + }; + } + + /// <summary> + /// Gets registration status for a feature. + /// </summary> + /// <param name="name">Feature name.</param> + /// <response code="501">Not implemented.</response> + /// <returns>Not Implemented.</returns> + /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception> + [Obsolete("Paid plugins are not supported")] + [HttpGet("Registrations/{name}")] + [ProducesResponseType(StatusCodes.Status501NotImplemented)] + public ActionResult GetRegistration([FromRoute] string? name) + { + // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins, + // delete all these registration endpoints. They are only kept for compatibility. + throw new NotImplementedException(); + } + } +} diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs new file mode 100644 index 000000000..baa3d80ac --- /dev/null +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Providers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Remote Images Controller. + /// </summary> + [Route("")] + public class RemoteImageController : BaseJellyfinApiController + { + private readonly IProviderManager _providerManager; + private readonly IServerApplicationPaths _applicationPaths; + private readonly IHttpClient _httpClient; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="RemoteImageController"/> class. + /// </summary> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> + /// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public RemoteImageController( + IProviderManager providerManager, + IServerApplicationPaths applicationPaths, + IHttpClient httpClient, + ILibraryManager libraryManager) + { + _providerManager = providerManager; + _applicationPaths = applicationPaths; + _httpClient = httpClient; + _libraryManager = libraryManager; + } + + /// <summary> + /// Gets available remote images for an item. + /// </summary> + /// <param name="itemId">Item Id.</param> + /// <param name="type">The image type.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="providerName">Optional. The image provider to use.</param> + /// <param name="includeAllLanguages">Optional. Include all languages.</param> + /// <response code="200">Remote Images returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>Remote Image Result.</returns> + [HttpGet("Items/{itemId}/RemoteImages")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<RemoteImageResult>> GetRemoteImages( + [FromRoute] Guid itemId, + [FromQuery] ImageType? type, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? providerName, + [FromQuery] bool includeAllLanguages = false) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + var images = await _providerManager.GetAvailableRemoteImages( + item, + new RemoteImageQuery(providerName ?? string.Empty) + { + IncludeAllLanguages = includeAllLanguages, + IncludeDisabledProviders = true, + ImageType = type + }, CancellationToken.None) + .ConfigureAwait(false); + + var imageArray = images.ToArray(); + var allProviders = _providerManager.GetRemoteImageProviderInfo(item); + if (type.HasValue) + { + allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value)); + } + + var result = new RemoteImageResult + { + TotalRecordCount = imageArray.Length, + Providers = allProviders.Select(o => o.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + + if (startIndex.HasValue) + { + imageArray = imageArray.Skip(startIndex.Value).ToArray(); + } + + if (limit.HasValue) + { + imageArray = imageArray.Take(limit.Value).ToArray(); + } + + result.Images = imageArray; + return result; + } + + /// <summary> + /// Gets available remote image providers for an item. + /// </summary> + /// <param name="itemId">Item Id.</param> + /// <response code="200">Returned remote image providers.</response> + /// <response code="404">Item not found.</response> + /// <returns>List of remote image providers.</returns> + [HttpGet("Items/{itemId}/RemoteImages/Providers")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + return Ok(_providerManager.GetRemoteImageProviderInfo(item)); + } + + /// <summary> + /// Gets a remote image. + /// </summary> + /// <param name="imageUrl">The image url.</param> + /// <response code="200">Remote image returned.</response> + /// <response code="404">Remote image not found.</response> + /// <returns>Image Stream.</returns> + [HttpGet("Images/Remote")] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetRemoteImage([FromQuery, Required] string imageUrl) + { + var urlHash = imageUrl.GetMD5(); + var pointerCachePath = GetFullCachePath(urlHash.ToString()); + + string? contentPath = null; + var hasFile = false; + + try + { + contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); + if (System.IO.File.Exists(contentPath)) + { + hasFile = true; + } + } + catch (FileNotFoundException) + { + // The file isn't cached yet + } + catch (IOException) + { + // The file isn't cached yet + } + + if (!hasFile) + { + await DownloadImage(imageUrl, urlHash, pointerCachePath).ConfigureAwait(false); + contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); + } + + if (string.IsNullOrEmpty(contentPath)) + { + return NotFound(); + } + + var contentType = MimeTypes.GetMimeType(contentPath); + return File(System.IO.File.OpenRead(contentPath), contentType); + } + + /// <summary> + /// Downloads a remote image for an item. + /// </summary> + /// <param name="itemId">Item Id.</param> + /// <param name="type">The image type.</param> + /// <param name="imageUrl">The image url.</param> + /// <response code="204">Remote image downloaded.</response> + /// <response code="404">Remote image not found.</response> + /// <returns>Download status.</returns> + [HttpPost("Items/{itemId}/RemoteImages/Download")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DownloadRemoteImage( + [FromRoute] Guid itemId, + [FromQuery, Required] ImageType type, + [FromQuery] string? imageUrl) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None) + .ConfigureAwait(false); + + item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Gets the full cache path. + /// </summary> + /// <param name="filename">The filename.</param> + /// <returns>System.String.</returns> + private string GetFullCachePath(string filename) + { + return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); + } + + /// <summary> + /// Downloads the image. + /// </summary> + /// <param name="url">The URL.</param> + /// <param name="urlHash">The URL hash.</param> + /// <param name="pointerCachePath">The pointer cache path.</param> + /// <returns>Task.</returns> + private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath) + { + using var result = await _httpClient.GetResponse(new HttpRequestOptions + { + Url = url, + BufferContent = false + }).ConfigureAwait(false); + var ext = result.ContentType.Split('/').Last(); + + var fullCachePath = GetFullCachePath(urlHash + "." + ext); + + Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); + await using (var stream = result.Content) + { + await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); + await stream.CopyToAsync(fileStream).ConfigureAwait(false); + } + + Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); + await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None) + .ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs new file mode 100644 index 000000000..e672070c0 --- /dev/null +++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Jellyfin.Api.Constants; +using MediaBrowser.Model.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Scheduled Tasks Controller. + /// </summary> + [Authorize(Policy = Policies.RequiresElevation)] + public class ScheduledTasksController : BaseJellyfinApiController + { + private readonly ITaskManager _taskManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ScheduledTasksController"/> class. + /// </summary> + /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> + public ScheduledTasksController(ITaskManager taskManager) + { + _taskManager = taskManager; + } + + /// <summary> + /// Get tasks. + /// </summary> + /// <param name="isHidden">Optional filter tasks that are hidden, or not.</param> + /// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param> + /// <response code="200">Scheduled tasks retrieved.</response> + /// <returns>The list of scheduled tasks.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<IScheduledTaskWorker> GetTasks( + [FromQuery] bool? isHidden, + [FromQuery] bool? isEnabled) + { + IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name); + + foreach (var task in tasks) + { + if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask) + { + if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden) + { + continue; + } + + if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled) + { + continue; + } + } + + yield return task; + } + } + + /// <summary> + /// Get task by id. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <response code="200">Task retrieved.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns> + [HttpGet("{taskId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<TaskInfo> GetTask([FromRoute, Required] string? taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(i => + string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase)); + + if (task == null) + { + return NotFound(); + } + + return ScheduledTaskHelpers.GetTaskInfo(task); + } + + /// <summary> + /// Start specified task. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <response code="204">Task started.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpPost("Running/{taskId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult StartTask([FromRoute] string? taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + + if (task == null) + { + return NotFound(); + } + + _taskManager.Execute(task, new TaskOptions()); + return NoContent(); + } + + /// <summary> + /// Stop specified task. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <response code="204">Task stopped.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpDelete("Running/{taskId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult StopTask([FromRoute, Required] string? taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + + if (task == null) + { + return NotFound(); + } + + _taskManager.Cancel(task); + return NoContent(); + } + + /// <summary> + /// Update specified task triggers. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <param name="triggerInfos">Triggers.</param> + /// <response code="204">Task triggers updated.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpPost("{taskId}/Triggers")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateTask( + [FromRoute, Required] string? taskId, + [FromBody, Required] TaskTriggerInfo[] triggerInfos) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + if (task == null) + { + return NotFound(); + } + + task.Triggers = triggerInfos; + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs new file mode 100644 index 000000000..e159a9666 --- /dev/null +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -0,0 +1,269 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Search; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Search controller. + /// </summary> + [Route("Search/Hints")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class SearchController : BaseJellyfinApiController + { + private readonly ISearchEngine _searchEngine; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IImageProcessor _imageProcessor; + + /// <summary> + /// Initializes a new instance of the <see cref="SearchController"/> class. + /// </summary> + /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> + /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param> + public SearchController( + ISearchEngine searchEngine, + ILibraryManager libraryManager, + IDtoService dtoService, + IImageProcessor imageProcessor) + { + _searchEngine = searchEngine; + _libraryManager = libraryManager; + _dtoService = dtoService; + _imageProcessor = imageProcessor; + } + + /// <summary> + /// Gets the search hint result. + /// </summary> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="userId">Optional. Supply a user id to search within a user's library or omit to search all.</param> + /// <param name="searchTerm">The search term to filter on.</param> + /// <param name="includeItemTypes">If specified, only results with the specified item types are returned. This allows multiple, comma delimeted.</param> + /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimeted.</param> + /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimeted.</param> + /// <param name="parentId">If specified, only children of the parent are returned.</param> + /// <param name="isMovie">Optional filter for movies.</param> + /// <param name="isSeries">Optional filter for series.</param> + /// <param name="isNews">Optional filter for news.</param> + /// <param name="isKids">Optional filter for kids.</param> + /// <param name="isSports">Optional filter for sports.</param> + /// <param name="includePeople">Optional filter whether to include people.</param> + /// <param name="includeMedia">Optional filter whether to include media.</param> + /// <param name="includeGenres">Optional filter whether to include genres.</param> + /// <param name="includeStudios">Optional filter whether to include studios.</param> + /// <param name="includeArtists">Optional filter whether to include artists.</param> + /// <response code="200">Search hint returned.</response> + /// <returns>An <see cref="SearchHintResult"/> with the results of the search.</returns> + [HttpGet] + [Description("Gets search hints based on a search term")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<SearchHintResult> Get( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] Guid? userId, + [FromQuery, Required] string? searchTerm, + [FromQuery] string? includeItemTypes, + [FromQuery] string? excludeItemTypes, + [FromQuery] string? mediaTypes, + [FromQuery] string? parentId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool includePeople = true, + [FromQuery] bool includeMedia = true, + [FromQuery] bool includeGenres = true, + [FromQuery] bool includeStudios = true, + [FromQuery] bool includeArtists = true) + { + var result = _searchEngine.GetSearchHints(new SearchQuery + { + Limit = limit, + SearchTerm = searchTerm, + IncludeArtists = includeArtists, + IncludeGenres = includeGenres, + IncludeMedia = includeMedia, + IncludePeople = includePeople, + IncludeStudios = includeStudios, + StartIndex = startIndex, + UserId = userId ?? Guid.Empty, + IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + ParentId = parentId, + + IsKids = isKids, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsSports = isSports + }); + + return new SearchHintResult + { + TotalRecordCount = result.TotalRecordCount, + SearchHints = result.Items.Select(GetSearchHintResult).ToArray() + }; + } + + /// <summary> + /// Gets the search hint result. + /// </summary> + /// <param name="hintInfo">The hint info.</param> + /// <returns>SearchHintResult.</returns> + private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) + { + var item = hintInfo.Item; + + var result = new SearchHint + { + Name = item.Name, + IndexNumber = item.IndexNumber, + ParentIndexNumber = item.ParentIndexNumber, + Id = item.Id, + Type = item.GetClientTypeName(), + MediaType = item.MediaType, + MatchedTerm = hintInfo.MatchedTerm, + RunTimeTicks = item.RunTimeTicks, + ProductionYear = item.ProductionYear, + ChannelId = item.ChannelId, + EndDate = item.EndDate + }; + + // legacy + result.ItemId = result.Id; + + if (item.IsFolder) + { + result.IsFolder = true; + } + + var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); + + if (primaryImageTag != null) + { + result.PrimaryImageTag = primaryImageTag; + result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); + } + + SetThumbImageInfo(result, item); + SetBackdropImageInfo(result, item); + + switch (item) + { + case IHasSeries hasSeries: + result.Series = hasSeries.SeriesName; + break; + case LiveTvProgram program: + result.StartDate = program.StartDate; + break; + case Series series: + if (series.Status.HasValue) + { + result.Status = series.Status.Value.ToString(); + } + + break; + case MusicAlbum album: + result.Artists = album.Artists; + result.AlbumArtist = album.AlbumArtist; + break; + case Audio song: + result.AlbumArtist = song.AlbumArtists?[0]; + result.Artists = song.Artists; + + MusicAlbum musicAlbum = song.AlbumEntity; + + if (musicAlbum != null) + { + result.Album = musicAlbum.Name; + result.AlbumId = musicAlbum.Id; + } + else + { + result.Album = song.Album; + } + + break; + } + + if (!item.ChannelId.Equals(Guid.Empty)) + { + var channel = _libraryManager.GetItemById(item.ChannelId); + result.ChannelName = channel?.Name; + } + + return result; + } + + private void SetThumbImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; + + if (itemWithImage == null && item is Episode) + { + itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb); + } + + if (itemWithImage == null) + { + itemWithImage = GetParentWithImage<BaseItem>(item, ImageType.Thumb); + } + + if (itemWithImage != null) + { + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); + + if (tag != null) + { + hint.ThumbImageTag = tag; + hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); + } + } + } + + private void SetBackdropImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) + ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop); + + if (itemWithImage != null) + { + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); + + if (tag != null) + { + hint.BackdropImageTag = tag; + hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); + } + } + } + + private T GetParentWithImage<T>(BaseItem item, ImageType type) + where T : BaseItem + { + return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type)); + } + } +} diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs new file mode 100644 index 000000000..48b57bdb7 --- /dev/null +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -0,0 +1,491 @@ +#pragma warning disable CA1801 + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The session controller. + /// </summary> + [Route("")] + public class SessionController : BaseJellyfinApiController + { + private readonly ISessionManager _sessionManager; + private readonly IUserManager _userManager; + private readonly IAuthorizationContext _authContext; + private readonly IDeviceManager _deviceManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SessionController"/> class. + /// </summary> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> + /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> + public SessionController( + ISessionManager sessionManager, + IUserManager userManager, + IAuthorizationContext authContext, + IDeviceManager deviceManager) + { + _sessionManager = sessionManager; + _userManager = userManager; + _authContext = authContext; + _deviceManager = deviceManager; + } + + /// <summary> + /// Gets a list of sessions. + /// </summary> + /// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param> + /// <param name="deviceId">Filter by device Id.</param> + /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param> + /// <response code="200">List of sessions returned.</response> + /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns> + [HttpGet("Sessions")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<SessionInfo>> GetSessions( + [FromQuery] Guid? controllableByUserId, + [FromQuery] string? deviceId, + [FromQuery] int? activeWithinSeconds) + { + var result = _sessionManager.Sessions; + + if (!string.IsNullOrEmpty(deviceId)) + { + result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); + } + + if (controllableByUserId.HasValue && !controllableByUserId.Equals(Guid.Empty)) + { + result = result.Where(i => i.SupportsRemoteControl); + + var user = _userManager.GetUserById(controllableByUserId.Value); + + if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) + { + result = result.Where(i => i.UserId.Equals(Guid.Empty) || i.ContainsUser(controllableByUserId.Value)); + } + + if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) + { + result = result.Where(i => !i.UserId.Equals(Guid.Empty)); + } + + if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) + { + var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value); + result = result.Where(i => i.LastActivityDate >= minActiveDate); + } + + result = result.Where(i => + { + if (!string.IsNullOrWhiteSpace(i.DeviceId)) + { + if (!_deviceManager.CanAccessDevice(user, i.DeviceId)) + { + return false; + } + } + + return true; + }); + } + + return Ok(result); + } + + /// <summary> + /// Instructs a session to browse to an item or view. + /// </summary> + /// <param name="sessionId">The session Id.</param> + /// <param name="itemType">The type of item to browse to.</param> + /// <param name="itemId">The Id of the item.</param> + /// <param name="itemName">The name of the item.</param> + /// <response code="204">Instruction sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Viewing")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DisplayContent( + [FromRoute, Required] string? sessionId, + [FromQuery, Required] string? itemType, + [FromQuery, Required] string? itemId, + [FromQuery, Required] string? itemName) + { + var command = new BrowseRequest + { + ItemId = itemId, + ItemName = itemName, + ItemType = itemType + }; + + _sessionManager.SendBrowseCommand( + RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, + sessionId, + command, + CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Instructs a session to play an item. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="itemIds">The ids of the items to play, comma delimited.</param> + /// <param name="startPositionTicks">The starting position of the first item.</param> + /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param> + /// <param name="playRequest">The <see cref="PlayRequest"/>.</param> + /// <response code="204">Instruction sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Playing")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult Play( + [FromRoute, Required] string? sessionId, + [FromQuery] Guid[] itemIds, + [FromQuery] long? startPositionTicks, + [FromQuery] PlayCommand playCommand, + [FromBody, Required] PlayRequest playRequest) + { + if (playRequest == null) + { + throw new ArgumentException("Request Body may not be null"); + } + + playRequest.ItemIds = itemIds; + playRequest.StartPositionTicks = startPositionTicks; + playRequest.PlayCommand = playCommand; + + _sessionManager.SendPlayCommand( + RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, + sessionId, + playRequest, + CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a playstate command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="playstateRequest">The <see cref="PlaystateRequest"/>.</param> + /// <response code="204">Playstate command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Playing/{command}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendPlaystateCommand( + [FromRoute, Required] string? sessionId, + [FromBody] PlaystateRequest playstateRequest) + { + _sessionManager.SendPlaystateCommand( + RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, + sessionId, + playstateRequest, + CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a system command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The command to send.</param> + /// <response code="204">System command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/System/{command}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendSystemCommand( + [FromRoute, Required] string? sessionId, + [FromRoute, Required] string? command) + { + var name = command; + if (Enum.TryParse(name, true, out GeneralCommandType commandType)) + { + name = commandType.ToString(); + } + + var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + var generalCommand = new GeneralCommand + { + Name = name, + ControllingUserId = currentSession.UserId + }; + + _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a general command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The command to send.</param> + /// <response code="204">General command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Command/{command}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendGeneralCommand( + [FromRoute, Required] string? sessionId, + [FromRoute, Required] string? command) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + + var generalCommand = new GeneralCommand + { + Name = command, + ControllingUserId = currentSession.UserId + }; + + _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a full general command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The <see cref="GeneralCommand"/>.</param> + /// <response code="204">Full general command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Command")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendFullGeneralCommand( + [FromRoute, Required] string? sessionId, + [FromBody, Required] GeneralCommand command) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + + if (command == null) + { + throw new ArgumentException("Request body may not be null"); + } + + command.ControllingUserId = currentSession.UserId; + + _sessionManager.SendGeneralCommand( + currentSession.Id, + sessionId, + command, + CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a command to a client to display a message to the user. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="text">The message test.</param> + /// <param name="header">The message header.</param> + /// <param name="timeoutMs">The message timeout. If omitted the user will have to confirm viewing the message.</param> + /// <response code="204">Message sent.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Message")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendMessageCommand( + [FromRoute, Required] string? sessionId, + [FromQuery, Required] string? text, + [FromQuery, Required] string? header, + [FromQuery] long? timeoutMs) + { + var command = new MessageCommand + { + Header = string.IsNullOrEmpty(header) ? "Message from Server" : header, + TimeoutMs = timeoutMs, + Text = text + }; + + _sessionManager.SendMessageCommand(RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, sessionId, command, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Adds an additional user to a session. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="userId">The user id.</param> + /// <response code="204">User added to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/User/{userId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddUserToSession( + [FromRoute, Required] string? sessionId, + [FromRoute] Guid userId) + { + _sessionManager.AddAdditionalUser(sessionId, userId); + return NoContent(); + } + + /// <summary> + /// Removes an additional user from a session. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="userId">The user id.</param> + /// <response code="204">User removed from session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Sessions/{sessionId}/User/{userId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveUserFromSession( + [FromRoute] string? sessionId, + [FromRoute] Guid userId) + { + _sessionManager.RemoveAdditionalUser(sessionId, userId); + return NoContent(); + } + + /// <summary> + /// Updates capabilities for a device. + /// </summary> + /// <param name="id">The session id.</param> + /// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param> + /// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param> + /// <param name="supportsMediaControl">Determines whether media can be played remotely..</param> + /// <param name="supportsSync">Determines whether sync is supported.</param> + /// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param> + /// <response code="204">Capabilities posted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Capabilities")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostCapabilities( + [FromQuery, Required] string? id, + [FromQuery] string? playableMediaTypes, + [FromQuery] string? supportedCommands, + [FromQuery] bool supportsMediaControl = false, + [FromQuery] bool supportsSync = false, + [FromQuery] bool supportsPersistentIdentifier = true) + { + if (string.IsNullOrWhiteSpace(id)) + { + id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + } + + _sessionManager.ReportCapabilities(id, new ClientCapabilities + { + PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true), + SupportedCommands = RequestHelpers.Split(supportedCommands, ',', true), + SupportsMediaControl = supportsMediaControl, + SupportsSync = supportsSync, + SupportsPersistentIdentifier = supportsPersistentIdentifier + }); + return NoContent(); + } + + /// <summary> + /// Updates capabilities for a device. + /// </summary> + /// <param name="id">The session id.</param> + /// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param> + /// <response code="204">Capabilities updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Capabilities/Full")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostFullCapabilities( + [FromQuery, Required] string? id, + [FromBody, Required] ClientCapabilities capabilities) + { + if (string.IsNullOrWhiteSpace(id)) + { + id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + } + + _sessionManager.ReportCapabilities(id, capabilities); + + return NoContent(); + } + + /// <summary> + /// Reports that a session is viewing an item. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="itemId">The item id.</param> + /// <response code="204">Session reported to server.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Viewing")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult ReportViewing( + [FromQuery] string? sessionId, + [FromQuery] string? itemId) + { + string session = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + + _sessionManager.ReportNowViewingItem(session, itemId); + return NoContent(); + } + + /// <summary> + /// Reports that a session has ended. + /// </summary> + /// <response code="204">Session end reported to server.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Logout")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult ReportSessionEnded() + { + AuthorizationInfo auth = _authContext.GetAuthorizationInfo(Request); + + _sessionManager.Logout(auth.Token); + return NoContent(); + } + + /// <summary> + /// Get all auth providers. + /// </summary> + /// <response code="200">Auth providers retrieved.</response> + /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns> + [HttpGet("Auth/Providers")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders() + { + return _userManager.GetAuthenticationProviders(); + } + + /// <summary> + /// Get all password reset providers. + /// </summary> + /// <response code="200">Password reset providers retrieved.</response> + /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns> + [HttpGet("Auto/PasswordResetProviders")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.RequiresElevation)] + public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders() + { + return _userManager.GetPasswordResetProviders(); + } + } +} diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 93fb22c4e..c8e3cc4f5 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -5,6 +5,7 @@ using Jellyfin.Api.Models.StartupDtos; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api.Controllers @@ -30,21 +31,27 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Api endpoint for completing the startup wizard. + /// Completes the startup wizard. /// </summary> + /// <response code="204">Startup wizard completed.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Complete")] - public void CompleteWizard() + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CompleteWizard() { _config.Configuration.IsStartupWizardCompleted = true; _config.SaveConfiguration(); + return NoContent(); } /// <summary> - /// Endpoint for getting the initial startup wizard configuration. + /// Gets the initial startup wizard configuration. /// </summary> - /// <returns>The initial startup wizard configuration.</returns> + /// <response code="200">Initial startup wizard configuration retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns> [HttpGet("Configuration")] - public StartupConfigurationDto GetStartupConfiguration() + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<StartupConfigurationDto> GetStartupConfiguration() { return new StartupConfigurationDto { @@ -55,41 +62,52 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Endpoint for updating the initial startup wizard configuration. + /// Sets the initial startup wizard configuration. /// </summary> /// <param name="uiCulture">The UI language culture.</param> /// <param name="metadataCountryCode">The metadata country code.</param> /// <param name="preferredMetadataLanguage">The preferred language for metadata.</param> + /// <response code="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Configuration")] - public void UpdateInitialConfiguration( - [FromForm] string uiCulture, - [FromForm] string metadataCountryCode, - [FromForm] string preferredMetadataLanguage) + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateInitialConfiguration( + [FromForm] string? uiCulture, + [FromForm] string? metadataCountryCode, + [FromForm] string? preferredMetadataLanguage) { _config.Configuration.UICulture = uiCulture; _config.Configuration.MetadataCountryCode = metadataCountryCode; _config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage; _config.SaveConfiguration(); + return NoContent(); } /// <summary> - /// Endpoint for (dis)allowing remote access and UPnP. + /// Sets remote access and UPnP. /// </summary> /// <param name="enableRemoteAccess">Enable remote access.</param> /// <param name="enableAutomaticPortMapping">Enable UPnP.</param> + /// <response code="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("RemoteAccess")] - public void SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping) + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping) { _config.Configuration.EnableRemoteAccess = enableRemoteAccess; _config.Configuration.EnableUPnP = enableAutomaticPortMapping; _config.SaveConfiguration(); + return NoContent(); } /// <summary> - /// Endpoint for returning the first user. + /// Gets the first user. /// </summary> + /// <response code="200">Initial user retrieved.</response> /// <returns>The first user.</returns> [HttpGet("User")] + [HttpGet("FirstUser", Name = "GetFirstUser_2")] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task<StartupUserDto> GetFirstUser() { // TODO: Remove this method when startup wizard no longer requires an existing user. @@ -103,12 +121,17 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Endpoint for updating the user name and password. + /// Sets the user name and password. /// </summary> /// <param name="startupUserDto">The DTO containing username and password.</param> - /// <returns>The async task.</returns> + /// <response code="204">Updated user name and password.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous update operation. + /// The task result contains a <see cref="NoContentResult"/> indicating success. + /// </returns> [HttpPost("User")] - public async Task UpdateUser([FromForm] StartupUserDto startupUserDto) + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> UpdateStartupUser([FromForm] StartupUserDto startupUserDto) { var user = _userManager.Users.First(); @@ -120,6 +143,8 @@ namespace Jellyfin.Api.Controllers { await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); } + + return NoContent(); } } } diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs new file mode 100644 index 000000000..6f2787d93 --- /dev/null +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -0,0 +1,277 @@ +using System; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Dto; +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; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Studios controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class StudiosController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + + /// <summary> + /// Initializes a new instance of the <see cref="StudiosController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public StudiosController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } + + /// <summary> + /// Gets all studios from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">Optional. Search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Total record count.</param> + /// <response code="200">Studios returned.</response> + /// <returns>An <see cref="OkResult"/> containing the studios.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetStudios( + [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] string? 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] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions() + .AddItemFields(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 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 + }; + + 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 RequestHelpers.GetFilters(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<BaseItemDto> + { + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; + } + + /// <summary> + /// Gets a studio by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Studio returned.</response> + /// <returns>An <see cref="OkResult"/> containing the studio.</returns> + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetStudio([FromRoute] string name, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions().AddClientFields(Request); + + var item = _libraryManager.GetStudio(name); + if (userId.HasValue && !userId.Equals(Guid.Empty)) + { + var user = _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + return _dtoService.GetBaseItemDto(item, dtoOptions); + } + } +} diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs new file mode 100644 index 000000000..988acccc3 --- /dev/null +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Providers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Subtitle controller. + /// </summary> + [Route("")] + public class SubtitleController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly ISubtitleManager _subtitleManager; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly IAuthorizationContext _authContext; + private readonly ILogger<SubtitleController> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param> + /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param> + public SubtitleController( + ILibraryManager libraryManager, + ISubtitleManager subtitleManager, + ISubtitleEncoder subtitleEncoder, + IMediaSourceManager mediaSourceManager, + IProviderManager providerManager, + IFileSystem fileSystem, + IAuthorizationContext authContext, + ILogger<SubtitleController> logger) + { + _libraryManager = libraryManager; + _subtitleManager = subtitleManager; + _subtitleEncoder = subtitleEncoder; + _mediaSourceManager = mediaSourceManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _authContext = authContext; + _logger = logger; + } + + /// <summary> + /// Deletes an external subtitle file. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="index">The index of the subtitle file.</param> + /// <response code="204">Subtitle deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Videos/{itemId}/Subtitles/{index}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<Task> DeleteSubtitle( + [FromRoute] Guid itemId, + [FromRoute] int index) + { + var item = _libraryManager.GetItemById(itemId); + + if (item == null) + { + return NotFound(); + } + + _subtitleManager.DeleteSubtitles(item, index); + return NoContent(); + } + + /// <summary> + /// Search remote subtitles. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="language">The language of the subtitles.</param> + /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> + /// <response code="200">Subtitles retrieved.</response> + /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> + [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( + [FromRoute] Guid itemId, + [FromRoute, Required] string? language, + [FromQuery] bool? isPerfectMatch) + { + var video = (Video)_libraryManager.GetItemById(itemId); + + return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Downloads a remote subtitle. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="subtitleId">The subtitle id.</param> + /// <response code="204">Subtitle downloaded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> DownloadRemoteSubtitles( + [FromRoute] Guid itemId, + [FromRoute, Required] string? subtitleId) + { + var video = (Video)_libraryManager.GetItemById(itemId); + + try + { + await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) + .ConfigureAwait(false); + + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading subtitles"); + } + + return NoContent(); + } + + /// <summary> + /// Gets the remote subtitles. + /// </summary> + /// <param name="id">The item id.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns> + [HttpGet("Providers/Subtitles/Subtitles/{id}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Application.Octet)] + public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string? id) + { + var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); + + return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); + } + + /// <summary> + /// Gets subtitles in a specified format. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="format">The format of the returned subtitle.</param> + /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> + /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> + /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> + [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")] + [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetSubtitle( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string? mediaSourceId, + [FromRoute, Required] int index, + [FromRoute, Required] string? format, + [FromQuery] long? endPositionTicks, + [FromQuery] bool copyTimestamps = false, + [FromQuery] bool addVttTimeMap = false, + [FromRoute] long startPositionTicks = 0) + { + if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) + { + format = "json"; + } + + if (string.IsNullOrEmpty(format)) + { + var item = (Video)_libraryManager.GetItemById(itemId); + + var idString = itemId.ToString("N", CultureInfo.InvariantCulture); + var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) + .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); + + var subtitleStream = mediaSource.MediaStreams + .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); + + FileStream stream = new FileStream(subtitleStream.Path, FileMode.Open, FileAccess.Read); + return File(stream, MimeTypes.GetMimeType(subtitleStream.Path)); + } + + if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) + { + await using Stream stream = await EncodeSubtitles(itemId, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); + using var reader = new StreamReader(stream); + + var text = await reader.ReadToEndAsync().ConfigureAwait(false); + + text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal); + + return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); + } + + return File( + await EncodeSubtitles( + itemId, + mediaSourceId, + index, + format, + startPositionTicks, + endPositionTicks, + copyTimestamps).ConfigureAwait(false), + MimeTypes.GetMimeType("file." + format)); + } + + /// <summary> + /// Gets an HLS subtitle playlist. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="segmentLength">The subtitle segment length.</param> + /// <response code="200">Subtitle playlist retrieved.</response> + /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> + [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> GetSubtitlePlaylist( + [FromRoute] Guid itemId, + [FromRoute] int index, + [FromRoute] string? mediaSourceId, + [FromQuery, Required] int segmentLength) + { + var item = (Video)_libraryManager.GetItemById(itemId); + + var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); + + var runtime = mediaSource.RunTimeTicks ?? -1; + + if (runtime <= 0) + { + throw new ArgumentException("HLS Subtitles are not supported for this media."); + } + + var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks; + if (segmentLengthTicks <= 0) + { + throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)"); + } + + var builder = new StringBuilder(); + builder.AppendLine("#EXTM3U") + .Append("#EXT-X-TARGETDURATION:") + .AppendLine(segmentLength.ToString(CultureInfo.InvariantCulture)) + .AppendLine("#EXT-X-VERSION:3") + .AppendLine("#EXT-X-MEDIA-SEQUENCE:0") + .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); + + long positionTicks = 0; + + var accessToken = _authContext.GetAuthorizationInfo(Request).Token; + + while (positionTicks < runtime) + { + var remaining = runtime - positionTicks; + var lengthTicks = Math.Min(remaining, segmentLengthTicks); + + builder.Append("#EXTINF:") + .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture)) + .AppendLine(","); + + var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); + + var url = string.Format( + CultureInfo.CurrentCulture, + "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", + positionTicks.ToString(CultureInfo.InvariantCulture), + endPositionTicks.ToString(CultureInfo.InvariantCulture), + accessToken); + + builder.AppendLine(url); + + positionTicks += segmentLengthTicks; + } + + builder.AppendLine("#EXT-X-ENDLIST"); + return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } + + /// <summary> + /// Encodes a subtitle in the specified format. + /// </summary> + /// <param name="id">The media id.</param> + /// <param name="mediaSourceId">The source media id.</param> + /// <param name="index">The subtitle index.</param> + /// <param name="format">The format to convert to.</param> + /// <param name="startPositionTicks">The start position in ticks.</param> + /// <param name="endPositionTicks">The end position in ticks.</param> + /// <param name="copyTimestamps">Whether to copy the timestamps.</param> + /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns> + private Task<Stream> EncodeSubtitles( + Guid id, + string? mediaSourceId, + int index, + string format, + long startPositionTicks, + long? endPositionTicks, + bool copyTimestamps) + { + var item = _libraryManager.GetItemById(id); + + return _subtitleEncoder.GetSubtitles( + item, + mediaSourceId, + index, + format, + startPositionTicks, + endPositionTicks ?? 0, + copyTimestamps, + CancellationToken.None); + } + } +} diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs new file mode 100644 index 000000000..42db6b6a1 --- /dev/null +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -0,0 +1,88 @@ +using System; +using System.Linq; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The suggestions controller. + /// </summary> + [Route("")] + public class SuggestionsController : BaseJellyfinApiController + { + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SuggestionsController"/> class. + /// </summary> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public SuggestionsController( + IDtoService dtoService, + IUserManager userManager, + ILibraryManager libraryManager) + { + _dtoService = dtoService; + _userManager = userManager; + _libraryManager = libraryManager; + } + + /// <summary> + /// Gets suggestions. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="mediaType">The media types.</param> + /// <param name="type">The type.</param> + /// <param name="startIndex">Optional. The start index.</param> + /// <param name="limit">Optional. The limit.</param> + /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param> + /// <response code="200">Suggestions returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns> + [HttpGet("Users/{userId}/Suggestions")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( + [FromRoute] Guid userId, + [FromQuery] string? mediaType, + [FromQuery] string? type, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool enableTotalRecordCount = false) + { + var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null; + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) + { + OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), + MediaTypes = RequestHelpers.Split(mediaType!, ',', true), + IncludeItemTypes = RequestHelpers.Split(type!, ',', true), + IsVirtualItem = false, + StartIndex = startIndex, + Limit = limit, + DtoOptions = dtoOptions, + EnableTotalRecordCount = enableTotalRecordCount, + Recursive = true + }); + + var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); + + return new QueryResult<BaseItemDto> + { + TotalRecordCount = result.TotalRecordCount, + Items = dtoList + }; + } + } +} diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs new file mode 100644 index 000000000..e16a10ba4 --- /dev/null +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Model.SyncPlay; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The sync play controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class SyncPlayController : BaseJellyfinApiController + { + private readonly ISessionManager _sessionManager; + private readonly IAuthorizationContext _authorizationContext; + private readonly ISyncPlayManager _syncPlayManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayController"/> class. + /// </summary> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param> + public SyncPlayController( + ISessionManager sessionManager, + IAuthorizationContext authorizationContext, + ISyncPlayManager syncPlayManager) + { + _sessionManager = sessionManager; + _authorizationContext = authorizationContext; + _syncPlayManager = syncPlayManager; + } + + /// <summary> + /// Create a new SyncPlay group. + /// </summary> + /// <response code="204">New group created.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("New")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayCreateGroup() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + _syncPlayManager.NewGroup(currentSession, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Join an existing SyncPlay group. + /// </summary> + /// <param name="groupId">The sync play group id.</param> + /// <response code="204">Group join successful.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Join")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + + var joinRequest = new JoinGroupRequest() + { + GroupId = groupId + }; + + _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Leave the joined SyncPlay group. + /// </summary> + /// <response code="204">Group leave successful.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Leave")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayLeaveGroup() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Gets all SyncPlay groups. + /// </summary> + /// <param name="filterItemId">Optional. Filter by item id.</param> + /// <response code="200">Groups returned.</response> + /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns> + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<GroupInfoView>> SyncPlayGetGroups([FromQuery] Guid? filterItemId) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty)); + } + + /// <summary> + /// Request play in SyncPlay group. + /// </summary> + /// <response code="204">Play request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Play")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayPlay() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new PlaybackRequest() + { + Type = PlaybackRequestType.Play + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request pause in SyncPlay group. + /// </summary> + /// <response code="204">Pause request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Pause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayPause() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new PlaybackRequest() + { + Type = PlaybackRequestType.Pause + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request seek in SyncPlay group. + /// </summary> + /// <param name="positionTicks">The playback position in ticks.</param> + /// <response code="204">Seek request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Seek")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySeek([FromQuery] long positionTicks) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new PlaybackRequest() + { + Type = PlaybackRequestType.Seek, + PositionTicks = positionTicks + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request group wait in SyncPlay group while buffering. + /// </summary> + /// <param name="when">When the request has been made by the client.</param> + /// <param name="positionTicks">The playback position in ticks.</param> + /// <param name="bufferingDone">Whether the buffering is done.</param> + /// <response code="204">Buffering request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Buffering")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayBuffering([FromQuery] DateTime when, [FromQuery] long positionTicks, [FromQuery] bool bufferingDone) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new PlaybackRequest() + { + Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer, + When = when, + PositionTicks = positionTicks + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Update session ping. + /// </summary> + /// <param name="ping">The ping.</param> + /// <response code="204">Ping updated.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Ping")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayPing([FromQuery] double ping) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new PlaybackRequest() + { + Type = PlaybackRequestType.Ping, + Ping = Convert.ToInt64(ping) + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs new file mode 100644 index 000000000..bbfd163de --- /dev/null +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.System; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The system controller. + /// </summary> + public class SystemController : BaseJellyfinApiController + { + private readonly IServerApplicationHost _appHost; + private readonly IApplicationPaths _appPaths; + private readonly IFileSystem _fileSystem; + private readonly INetworkManager _network; + private readonly ILogger<SystemController> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="SystemController"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param> + public SystemController( + IServerConfigurationManager serverConfigurationManager, + IServerApplicationHost appHost, + IFileSystem fileSystem, + INetworkManager network, + ILogger<SystemController> logger) + { + _appPaths = serverConfigurationManager.ApplicationPaths; + _appHost = appHost; + _fileSystem = fileSystem; + _network = network; + _logger = logger; + } + + /// <summary> + /// Gets information about the server. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>A <see cref="SystemInfo"/> with info about the system.</returns> + [HttpGet("Info")] + [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<SystemInfo>> GetSystemInfo() + { + return await _appHost.GetSystemInfo(CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Gets public information about the server. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns> + [HttpGet("Info/Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PublicSystemInfo>> GetPublicSystemInfo() + { + return await _appHost.GetPublicSystemInfo(CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Pings the system. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>The server name.</returns> + [HttpGet("Ping", Name = "GetPingSystem")] + [HttpPost("Ping", Name = "PostPingSystem")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<string> PingSystem() + { + return _appHost.Name; + } + + /// <summary> + /// Restarts the application. + /// </summary> + /// <response code="204">Server restarted.</response> + /// <returns>No content. Server restarted.</returns> + [HttpPost("Restart")] + [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RestartApplication() + { + Task.Run(async () => + { + await Task.Delay(100).ConfigureAwait(false); + _appHost.Restart(); + }); + return NoContent(); + } + + /// <summary> + /// Shuts down the application. + /// </summary> + /// <response code="204">Server shut down.</response> + /// <returns>No content. Server shut down.</returns> + [HttpPost("Shutdown")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult ShutdownApplication() + { + Task.Run(async () => + { + await Task.Delay(100).ConfigureAwait(false); + await _appHost.Shutdown().ConfigureAwait(false); + }); + return NoContent(); + } + + /// <summary> + /// Gets a list of available server log files. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>An array of <see cref="LogFile"/> with the available log files.</returns> + [HttpGet("Logs")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<LogFile[]> GetServerLogs() + { + IEnumerable<FileSystemMetadata> files; + + try + { + files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error getting logs"); + files = Enumerable.Empty<FileSystemMetadata>(); + } + + var result = files.Select(i => new LogFile + { + DateCreated = _fileSystem.GetCreationTimeUtc(i), + DateModified = _fileSystem.GetLastWriteTimeUtc(i), + Name = i.Name, + Size = i.Length + }) + .OrderByDescending(i => i.DateModified) + .ThenByDescending(i => i.DateCreated) + .ThenBy(i => i.Name) + .ToArray(); + + return result; + } + + /// <summary> + /// Gets information about the request endpoint. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns> + [HttpGet("Endpoint")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<EndPointInfo> GetEndpointInfo() + { + return new EndPointInfo + { + IsLocal = Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress), + IsInNetwork = _network.IsInLocalNetwork(Request.HttpContext.Connection.RemoteIpAddress.ToString()) + }; + } + + /// <summary> + /// Gets a log file. + /// </summary> + /// <param name="name">The name of the log file to get.</param> + /// <response code="200">Log file retrieved.</response> + /// <returns>The log file.</returns> + [HttpGet("Logs/Log")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetLogFile([FromQuery, Required] string? name) + { + var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) + .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + + // For older files, assume fully static + var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; + + FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare); + return File(stream, "text/plain"); + } + + /// <summary> + /// Gets wake on lan information. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>An <see cref="IEnumerable{WakeOnLanInfo}"/> with the WakeOnLan infos.</returns> + [HttpGet("WakeOnLanInfo")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo() + { + var result = _appHost.GetWakeOnLanInfo(); + return Ok(result); + } + } +} diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs new file mode 100644 index 000000000..2dc744e7c --- /dev/null +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using MediaBrowser.Model.SyncPlay; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The time sync controller. + /// </summary> + [Route("")] + public class TimeSyncController : BaseJellyfinApiController + { + /// <summary> + /// Gets the current utc time. + /// </summary> + /// <response code="200">Time returned.</response> + /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns> + [HttpGet("GetUtcTime")] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + public ActionResult<UtcTimeResponse> GetUtcTime() + { + // Important to keep the following line at the beginning + var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); + + var response = new UtcTimeResponse(); + response.RequestReceptionTime = requestReceptionTime; + + // Important to keep the following two lines at the end + var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); + response.ResponseTransmissionTime = responseTransmissionTime; + + // Implementing NTP on such a high level results in this useless + // information being sent. On the other hand it enables future additions. + return response; + } + } +} diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs new file mode 100644 index 000000000..5157b08ae --- /dev/null +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -0,0 +1,281 @@ +using System; +using Jellyfin.Api.Constants; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The trailers controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class TrailersController : BaseJellyfinApiController + { + private readonly ItemsController _itemsController; + + /// <summary> + /// Initializes a new instance of the <see cref="TrailersController"/> class. + /// </summary> + /// <param name="itemsController">Instance of <see cref="ItemsController"/>.</param> + public TrailersController(ItemsController itemsController) + { + _itemsController = itemsController; + } + + /// <summary> + /// Finds movies and trailers similar to a given trailer. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> + /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> + /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> + /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> + /// <param name="hasTrailer">Optional filter by items with trailers.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="parentIndexNumber">Optional filter by parent index number.</param> + /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> + /// <param name="isHd">Optional filter by items that are HD or not.</param> + /// <param name="is4K">Optional filter by items that are 4K or not.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param> + /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> + /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> + /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> + /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> + /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> + /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> + /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> + /// <param name="searchTerm">Optional. Filter based on a search term.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="isPlayed">Optional filter by items that are played, or not.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param> + /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> + /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> + /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param> + /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param> + /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="isLocked">Optional filter by items that are locked.</param> + /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> + /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> + /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> + /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> + /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> + /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> + /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> + /// <param name="is3D">Optional filter by items that are 3D, or not.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param> + /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetTrailers( + [FromQuery] Guid? userId, + [FromQuery] string? maxOfficialRating, + [FromQuery] bool? hasThemeSong, + [FromQuery] bool? hasThemeVideo, + [FromQuery] bool? hasSubtitles, + [FromQuery] bool? hasSpecialFeature, + [FromQuery] bool? hasTrailer, + [FromQuery] string? adjacentTo, + [FromQuery] int? parentIndexNumber, + [FromQuery] bool? hasParentalRating, + [FromQuery] bool? isHd, + [FromQuery] bool? is4K, + [FromQuery] string? locationTypes, + [FromQuery] string? excludeLocationTypes, + [FromQuery] bool? isMissing, + [FromQuery] bool? isUnaired, + [FromQuery] double? minCommunityRating, + [FromQuery] double? minCriticRating, + [FromQuery] DateTime? minPremiereDate, + [FromQuery] DateTime? minDateLastSaved, + [FromQuery] DateTime? minDateLastSavedForUser, + [FromQuery] DateTime? maxPremiereDate, + [FromQuery] bool? hasOverview, + [FromQuery] bool? hasImdbId, + [FromQuery] bool? hasTmdbId, + [FromQuery] bool? hasTvdbId, + [FromQuery] string? excludeItemIds, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? recursive, + [FromQuery] string? searchTerm, + [FromQuery] string? sortOrder, + [FromQuery] string? parentId, + [FromQuery] string? fields, + [FromQuery] string? excludeItemTypes, + [FromQuery] string? filters, + [FromQuery] bool? isFavorite, + [FromQuery] string? mediaTypes, + [FromQuery] string? imageTypes, + [FromQuery] string? sortBy, + [FromQuery] bool? isPlayed, + [FromQuery] string? genres, + [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? artists, + [FromQuery] string? excludeArtistIds, + [FromQuery] string? artistIds, + [FromQuery] string? albumArtistIds, + [FromQuery] string? contributingArtistIds, + [FromQuery] string? albums, + [FromQuery] string? albumIds, + [FromQuery] string? ids, + [FromQuery] string? videoTypes, + [FromQuery] string? minOfficialRating, + [FromQuery] bool? isLocked, + [FromQuery] bool? isPlaceHolder, + [FromQuery] bool? hasOfficialRating, + [FromQuery] bool? collapseBoxSetItems, + [FromQuery] int? minWidth, + [FromQuery] int? minHeight, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? is3D, + [FromQuery] string? seriesStatus, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] string? studioIds, + [FromQuery] string? genreIds, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true) + { + var includeItemTypes = "Trailer"; + + return _itemsController + .GetItems( + userId, + userId, + maxOfficialRating, + hasThemeSong, + hasThemeVideo, + hasSubtitles, + hasSpecialFeature, + hasTrailer, + adjacentTo, + parentIndexNumber, + hasParentalRating, + isHd, + is4K, + locationTypes, + excludeLocationTypes, + isMissing, + isUnaired, + minCommunityRating, + minCriticRating, + minPremiereDate, + minDateLastSaved, + minDateLastSavedForUser, + maxPremiereDate, + hasOverview, + hasImdbId, + hasTmdbId, + hasTvdbId, + excludeItemIds, + startIndex, + limit, + recursive, + searchTerm, + sortOrder, + parentId, + fields, + excludeItemTypes, + includeItemTypes, + filters, + isFavorite, + mediaTypes, + imageTypes, + sortBy, + isPlayed, + genres, + officialRatings, + tags, + years, + enableUserData, + imageTypeLimit, + enableImageTypes, + person, + personIds, + personTypes, + studios, + artists, + excludeArtistIds, + artistIds, + albumArtistIds, + contributingArtistIds, + albums, + albumIds, + ids, + videoTypes, + minOfficialRating, + isLocked, + isPlaceHolder, + hasOfficialRating, + collapseBoxSetItems, + minWidth, + minHeight, + maxWidth, + maxHeight, + is3D, + seriesStatus, + nameStartsWithOrGreater, + nameStartsWith, + nameLessThan, + studioIds, + genreIds, + enableTotalRecordCount, + enableImages); + } + } +} diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs new file mode 100644 index 000000000..f463ab889 --- /dev/null +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.TV; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The tv shows controller. + /// </summary> + [Route("Shows")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class TvShowsController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly ITVSeriesManager _tvSeriesManager; + + /// <summary> + /// Initializes a new instance of the <see cref="TvShowsController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param> + public TvShowsController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService, + ITVSeriesManager tvSeriesManager) + { + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _tvSeriesManager = tvSeriesManager; + } + + /// <summary> + /// Gets a list of next up episodes. + /// </summary> + /// <param name="userId">The user id of the user to get the next up episodes for.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="seriesId">Optional. Filter by series id.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="enableImges">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> + [HttpGet("NextUp")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetNextUp( + [FromQuery, Required] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? fields, + [FromQuery] string? seriesId, + [FromQuery] string? parentId, + [FromQuery] bool? enableImges, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] bool enableTotalRecordCount = true) + { + var options = new DtoOptions() + .AddItemFields(fields!) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); + + var result = _tvSeriesManager.GetNextUp( + new NextUpQuery + { + Limit = limit, + ParentId = parentId, + SeriesId = seriesId, + StartIndex = startIndex, + UserId = userId ?? Guid.Empty, + EnableTotalRecordCount = enableTotalRecordCount + }, + options); + + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user); + + return new QueryResult<BaseItemDto> + { + TotalRecordCount = result.TotalRecordCount, + Items = returnItems + }; + } + + /// <summary> + /// Gets a list of upcoming episodes. + /// </summary> + /// <param name="userId">The user id of the user to get the upcoming episodes for.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">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.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="enableImges">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> + [HttpGet("Upcoming")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes( + [FromQuery, Required] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? fields, + [FromQuery] string? parentId, + [FromQuery] bool? enableImges, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] bool? enableUserData) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1); + + var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); + + var options = new DtoOptions() + .AddItemFields(fields!) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); + + var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { nameof(Episode) }, + OrderBy = new[] { ItemSortBy.PremiereDate, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(), + MinPremiereDate = minPremiereDate, + StartIndex = startIndex, + Limit = limit, + ParentId = parentIdGuid, + Recursive = true, + DtoOptions = options + }); + + var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user); + + return new QueryResult<BaseItemDto> + { + TotalRecordCount = itemsResult.Count, + Items = returnItems + }; + } + + /// <summary> + /// Gets episodes for a tv season. + /// </summary> + /// <param name="seriesId">The series id.</param> + /// <param name="userId">The user id.</param> + /// <param name="fields">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.</param> + /// <param name="season">Optional filter by season number.</param> + /// <param name="seasonId">Optional. Filter by season id.</param> + /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="startItemId">Optional. Skip through the list until a given item is found.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> + [HttpGet("{seriesId}/Episodes")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QueryResult<BaseItemDto>> GetEpisodes( + [FromRoute, Required] string? seriesId, + [FromQuery, Required] Guid? userId, + [FromQuery] string? fields, + [FromQuery] int? season, + [FromQuery] string? seasonId, + [FromQuery] bool? isMissing, + [FromQuery] string? adjacentTo, + [FromQuery] string? startItemId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] string? sortBy) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + List<BaseItem> episodes; + + var dtoOptions = new DtoOptions() + .AddItemFields(fields!) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + + if (!string.IsNullOrWhiteSpace(seasonId)) // Season id was supplied. Get episodes by season id. + { + var item = _libraryManager.GetItemById(new Guid(seasonId)); + if (!(item is Season seasonItem)) + { + return NotFound("No season exists with Id " + seasonId); + } + + episodes = seasonItem.GetEpisodes(user, dtoOptions); + } + else if (season.HasValue) // Season number was supplied. Get episodes by season number + { + if (!(_libraryManager.GetItemById(seriesId) is Series series)) + { + return NotFound("Series not found"); + } + + var seasonItem = series + .GetSeasons(user, dtoOptions) + .FirstOrDefault(i => i.IndexNumber == season.Value); + + episodes = seasonItem == null ? + new List<BaseItem>() + : ((Season)seasonItem).GetEpisodes(user, dtoOptions); + } + else // No season number or season id was supplied. Returning all episodes. + { + if (!(_libraryManager.GetItemById(seriesId) is Series series)) + { + return NotFound("Series not found"); + } + + episodes = series.GetEpisodes(user, dtoOptions).ToList(); + } + + // Filter after the fact in case the ui doesn't want them + if (isMissing.HasValue) + { + var val = isMissing.Value; + episodes = episodes + .Where(i => ((Episode)i).IsMissingEpisode == val) + .ToList(); + } + + if (!string.IsNullOrWhiteSpace(startItemId)) + { + episodes = episodes + .SkipWhile(i => !string.Equals(i.Id.ToString("N", CultureInfo.InvariantCulture), startItemId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + // This must be the last filter + if (!string.IsNullOrEmpty(adjacentTo)) + { + episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo).ToList(); + } + + if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) + { + episodes.Shuffle(); + } + + var returnItems = episodes; + + if (startIndex.HasValue || limit.HasValue) + { + returnItems = ApplyPaging(episodes, startIndex, limit).ToList(); + } + + var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); + + return new QueryResult<BaseItemDto> + { + TotalRecordCount = episodes.Count, + Items = dtos + }; + } + + /// <summary> + /// Gets seasons for a tv series. + /// </summary> + /// <param name="seriesId">The series id.</param> + /// <param name="userId">The user id.</param> + /// <param name="fields">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.</param> + /// <param name="isSpecialSeason">Optional. Filter by special season.</param> + /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> + [HttpGet("{seriesId}/Seasons")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QueryResult<BaseItemDto>> GetSeasons( + [FromRoute, Required] string? seriesId, + [FromQuery, Required] Guid? userId, + [FromQuery] string? fields, + [FromQuery] bool? isSpecialSeason, + [FromQuery] bool? isMissing, + [FromQuery] string? adjacentTo, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] bool? enableUserData) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + if (!(_libraryManager.GetItemById(seriesId) is Series series)) + { + return NotFound("Series not found"); + } + + var seasons = series.GetItemList(new InternalItemsQuery(user) + { + IsMissing = isMissing, + IsSpecialSeason = isSpecialSeason, + AdjacentTo = adjacentTo + }); + + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + + var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); + + return new QueryResult<BaseItemDto> + { + TotalRecordCount = returnItems.Count, + Items = returnItems + }; + } + + /// <summary> + /// Applies the paging. + /// </summary> + /// <param name="items">The items.</param> + /// <param name="startIndex">The start index.</param> + /// <param name="limit">The limit.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit) + { + // Start at + if (startIndex.HasValue) + { + items = items.Skip(startIndex.Value); + } + + // Return limit + if (limit.HasValue) + { + items = items.Take(limit.Value); + } + + return items; + } + } +} diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs new file mode 100644 index 000000000..75df16aa7 --- /dev/null +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.VideoDtos; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.MediaInfo; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The universal audio controller. + /// </summary> + [Route("")] + public class UniversalAudioController : BaseJellyfinApiController + { + private readonly IAuthorizationContext _authorizationContext; + private readonly MediaInfoController _mediaInfoController; + private readonly DynamicHlsController _dynamicHlsController; + private readonly AudioController _audioController; + + /// <summary> + /// Initializes a new instance of the <see cref="UniversalAudioController"/> class. + /// </summary> + /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="mediaInfoController">Instance of the <see cref="MediaInfoController"/>.</param> + /// <param name="dynamicHlsController">Instance of the <see cref="DynamicHlsController"/>.</param> + /// <param name="audioController">Instance of the <see cref="AudioController"/>.</param> + public UniversalAudioController( + IAuthorizationContext authorizationContext, + MediaInfoController mediaInfoController, + DynamicHlsController dynamicHlsController, + AudioController audioController) + { + _authorizationContext = authorizationContext; + _mediaInfoController = mediaInfoController; + _dynamicHlsController = dynamicHlsController; + _audioController = audioController; + } + + /// <summary> + /// Gets an audio stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">Optional. The audio container.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="userId">Optional. The user id.</param> + /// <param name="audioCodec">Optional. The audio codec to transcode to.</param> + /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param> + /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="transcodingContainer">Optional. The container to transcode to.</param> + /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param> + /// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param> + /// <response code="200">Audio stream returned.</response> + /// <response code="302">Redirected to remote audio stream.</response> + /// <returns>A <see cref="Task"/> containing the audio file.</returns> + [HttpGet("Audio/{itemId}/universal")] + [HttpGet("Audio/{itemId}/{universal=universal}.{container?}", Name = "GetUniversalAudioStream_2")] + [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] + [HttpHead("Audio/{itemId}/{universal=universal}.{container?}", Name = "HeadUniversalAudioStream_2")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status302Found)] + public async Task<ActionResult> GetUniversalAudioStream( + [FromRoute] Guid itemId, + [FromRoute] string? container, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] Guid? userId, + [FromQuery] string? audioCodec, + [FromQuery] int? maxAudioChannels, + [FromQuery] int? transcodingAudioChannels, + [FromQuery] long? maxStreamingBitrate, + [FromQuery] long? startTimeTicks, + [FromQuery] string? transcodingContainer, + [FromQuery] string? transcodingProtocol, + [FromQuery] int? maxAudioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] bool? enableRemoteMedia, + [FromQuery] bool breakOnNonKeyFrames, + [FromQuery] bool enableRedirection = true) + { + bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); + _authorizationContext.GetAuthorizationInfo(Request).DeviceId = deviceId; + + var playbackInfoResult = await _mediaInfoController.GetPostedPlaybackInfo( + itemId, + userId, + maxStreamingBitrate, + startTimeTicks, + null, + null, + maxAudioChannels, + mediaSourceId, + null, + new DeviceProfileDto { DeviceProfile = deviceProfile }) + .ConfigureAwait(false); + var mediaSource = playbackInfoResult.Value.MediaSources[0]; + + if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http) + { + if (enableRedirection) + { + if (mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value) + { + return Redirect(mediaSource.Path); + } + } + } + + var isStatic = mediaSource.SupportsDirectStream; + if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) + { + var transcodingProfile = deviceProfile.TranscodingProfiles[0]; + + // hls segment container can only be mpegts or fmp4 per ffmpeg documentation + // TODO: remove this when we switch back to the segment muxer + var supportedHlsContainers = new[] { "mpegts", "fmp4" }; + + if (isHeadRequest) + { + _dynamicHlsController.Request.Method = HttpMethod.Head.Method; + } + + return await _dynamicHlsController.GetMasterHlsAudioPlaylist( + itemId, + ".m3u8", + isStatic, + null, + null, + null, + playbackInfoResult.Value.PlaySessionId, + // fallback to mpegts if device reports some weird value unsupported by hls + Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "mpegts", + null, + null, + mediaSource.Id, + deviceId, + transcodingProfile.AudioCodec, + null, + null, + null, + transcodingProfile.BreakOnNonKeyFrames, + maxAudioSampleRate, + maxAudioBitDepth, + null, + isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + maxAudioChannels, + null, + null, + null, + null, + null, + startTimeTicks, + null, + null, + null, + null, + SubtitleDeliveryMethod.Hls, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), + null, + null, + EncodingContext.Static, + new Dictionary<string, string>()) + .ConfigureAwait(false); + } + else + { + if (isHeadRequest) + { + _audioController.Request.Method = HttpMethod.Head.Method; + } + + return await _audioController.GetAudioStream( + itemId, + isStatic ? null : ("." + mediaSource.TranscodingContainer), + isStatic, + null, + null, + null, + playbackInfoResult.Value.PlaySessionId, + null, + null, + null, + mediaSource.Id, + deviceId, + audioCodec, + null, + null, + null, + breakOnNonKeyFrames, + maxAudioSampleRate, + maxAudioBitDepth, + isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + null, + maxAudioChannels, + null, + null, + null, + null, + null, + startTimeTicks, + null, + null, + null, + null, + SubtitleDeliveryMethod.Embed, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), + null, + null, + null, + null) + .ConfigureAwait(false); + } + } + + private DeviceProfile GetDeviceProfile( + string? container, + string? transcodingContainer, + string? audioCodec, + string? transcodingProtocol, + bool? breakOnNonKeyFrames, + int? transcodingAudioChannels, + int? maxAudioSampleRate, + int? maxAudioBitDepth, + int? maxAudioChannels) + { + var deviceProfile = new DeviceProfile(); + + var directPlayProfiles = new List<DirectPlayProfile>(); + + var containers = RequestHelpers.Split(container, ',', true); + + foreach (var cont in containers) + { + var parts = RequestHelpers.Split(cont, ',', true); + + var audioCodecs = parts.Length == 1 ? null : string.Join(",", parts.Skip(1).ToArray()); + + directPlayProfiles.Add(new DirectPlayProfile { Type = DlnaProfileType.Audio, Container = parts[0], AudioCodec = audioCodecs }); + } + + deviceProfile.DirectPlayProfiles = directPlayProfiles.ToArray(); + + deviceProfile.TranscodingProfiles = new[] + { + new TranscodingProfile + { + Type = DlnaProfileType.Audio, + Context = EncodingContext.Streaming, + Container = transcodingContainer, + AudioCodec = audioCodec, + Protocol = transcodingProtocol, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture) + } + }; + + var codecProfiles = new List<CodecProfile>(); + var conditions = new List<ProfileCondition>(); + + if (maxAudioSampleRate.HasValue) + { + // codec profile + conditions.Add(new ProfileCondition { Condition = ProfileConditionType.LessThanEqual, IsRequired = false, Property = ProfileConditionValue.AudioSampleRate, Value = maxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) }); + } + + if (maxAudioBitDepth.HasValue) + { + // codec profile + conditions.Add(new ProfileCondition { Condition = ProfileConditionType.LessThanEqual, IsRequired = false, Property = ProfileConditionValue.AudioBitDepth, Value = maxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture) }); + } + + if (maxAudioChannels.HasValue) + { + // codec profile + conditions.Add(new ProfileCondition { Condition = ProfileConditionType.LessThanEqual, IsRequired = false, Property = ProfileConditionValue.AudioChannels, Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) }); + } + + if (conditions.Count > 0) + { + // codec profile + codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = container, Conditions = conditions.ToArray() }); + } + + deviceProfile.CodecProfiles = codecProfiles.ToArray(); + + return deviceProfile; + } + } +} diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs new file mode 100644 index 000000000..272312522 --- /dev/null +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -0,0 +1,541 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.UserDtos; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Users; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// User controller. + /// </summary> + [Route("Users")] + public class UserController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly ISessionManager _sessionManager; + private readonly INetworkManager _networkManager; + private readonly IDeviceManager _deviceManager; + private readonly IAuthorizationContext _authContext; + private readonly IServerConfigurationManager _config; + + /// <summary> + /// Initializes a new instance of the <see cref="UserController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public UserController( + IUserManager userManager, + ISessionManager sessionManager, + INetworkManager networkManager, + IDeviceManager deviceManager, + IAuthorizationContext authContext, + IServerConfigurationManager config) + { + _userManager = userManager; + _sessionManager = sessionManager; + _networkManager = networkManager; + _deviceManager = deviceManager; + _authContext = authContext; + _config = config; + } + + /// <summary> + /// Gets a list of users. + /// </summary> + /// <param name="isHidden">Optional filter by IsHidden=true or false.</param> + /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param> + /// <response code="200">Users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns> + [HttpGet] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<UserDto>> GetUsers( + [FromQuery] bool? isHidden, + [FromQuery] bool? isDisabled) + { + var users = Get(isHidden, isDisabled, false, false); + return Ok(users); + } + + /// <summary> + /// Gets a list of publicly visible users for display on a login screen. + /// </summary> + /// <response code="200">Public users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns> + [HttpGet("Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<UserDto>> GetPublicUsers() + { + // If the startup wizard hasn't been completed then just return all users + if (!_config.Configuration.IsStartupWizardCompleted) + { + return Ok(Get(false, false, false, false)); + } + + return Ok(Get(false, false, true, true)); + } + + /// <summary> + /// Gets a user by Id. + /// </summary> + /// <param name="userId">The user id.</param> + /// <response code="200">User returned.</response> + /// <response code="404">User not found.</response> + /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns> + [HttpGet("{userId}")] + [Authorize(Policy = Policies.IgnoreParentalControl)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<UserDto> GetUserById([FromRoute] Guid userId) + { + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound("User not found"); + } + + var result = _userManager.GetUserDto(user, HttpContext.Connection.RemoteIpAddress.ToString()); + return result; + } + + /// <summary> + /// Deletes a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <response code="200">User deleted.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns> + [HttpDelete("{userId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteUser([FromRoute] Guid userId) + { + var user = _userManager.GetUserById(userId); + _sessionManager.RevokeUserTokens(user.Id, null); + _userManager.DeleteUser(userId); + return NoContent(); + } + + /// <summary> + /// Authenticates a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="pw">The password as plain text.</param> + /// <param name="password">The password sha1-hash.</param> + /// <response code="200">User authenticated.</response> + /// <response code="403">Sha1-hashed password only is not allowed.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns> + [HttpPost("{userId}/Authenticate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUser( + [FromRoute, Required] Guid userId, + [FromQuery, Required] string? pw, + [FromQuery] string? password) + { + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound("User not found"); + } + + if (!string.IsNullOrEmpty(password) && string.IsNullOrEmpty(pw)) + { + return Forbid("Only sha1 password is not allowed."); + } + + // Password should always be null + AuthenticateUserByName request = new AuthenticateUserByName + { + Username = user.Username, + Password = null, + Pw = pw + }; + return await AuthenticateUserByName(request).ConfigureAwait(false); + } + + /// <summary> + /// Authenticates a user by name. + /// </summary> + /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param> + /// <response code="200">User authenticated.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> + [HttpPost("AuthenticateByName")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) + { + var auth = _authContext.GetAuthorizationInfo(Request); + + try + { + var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest + { + App = auth.Client, + AppVersion = auth.Version, + DeviceId = auth.DeviceId, + DeviceName = auth.Device, + Password = request.Pw, + PasswordSha1 = request.Password, + RemoteEndPoint = HttpContext.Connection.RemoteIpAddress.ToString(), + Username = request.Username + }).ConfigureAwait(false); + + return result; + } + catch (SecurityException e) + { + // rethrow adding IP address to message + throw new SecurityException($"[{HttpContext.Connection.RemoteIpAddress}] {e.Message}", e); + } + } + + /// <summary> + /// Updates a user's password. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param> + /// <response code="200">Password successfully reset.</response> + /// <response code="403">User is not allowed to update the password.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> + [HttpPost("{userId}/Password")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateUserPassword( + [FromRoute] Guid userId, + [FromBody] UpdateUserPassword request) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return Forbid("User is not allowed to update the password."); + } + + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound("User not found"); + } + + if (request.ResetPassword) + { + await _userManager.ResetPassword(user).ConfigureAwait(false); + } + else + { + var success = await _userManager.AuthenticateUser( + user.Username, + request.CurrentPw, + request.CurrentPw, + HttpContext.Connection.RemoteIpAddress.ToString(), + false).ConfigureAwait(false); + + if (success == null) + { + return Forbid("Invalid user or password entered."); + } + + await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); + + var currentToken = _authContext.GetAuthorizationInfo(Request).Token; + + _sessionManager.RevokeUserTokens(user.Id, currentToken); + } + + return NoContent(); + } + + /// <summary> + /// Updates a user's easy password. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param> + /// <response code="200">Password successfully reset.</response> + /// <response code="403">User is not allowed to update the password.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> + [HttpPost("{userId}/EasyPassword")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateUserEasyPassword( + [FromRoute] Guid userId, + [FromBody] UpdateUserEasyPassword request) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return Forbid("User is not allowed to update the easy password."); + } + + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound("User not found"); + } + + if (request.ResetPassword) + { + _userManager.ResetEasyPassword(user); + } + else + { + _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword); + } + + return NoContent(); + } + + /// <summary> + /// Updates a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="updateUser">The updated user model.</param> + /// <response code="204">User updated.</response> + /// <response code="400">User information was not supplied.</response> + /// <response code="403">User update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns> + [HttpPost("{userId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> UpdateUser( + [FromRoute] Guid userId, + [FromBody] UserDto updateUser) + { + if (updateUser == null) + { + return BadRequest(); + } + + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) + { + return Forbid("User update not allowed."); + } + + 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 + { + await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + _userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration); + } + + return NoContent(); + } + + /// <summary> + /// Updates a user policy. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="newPolicy">The new user policy.</param> + /// <response code="204">User policy updated.</response> + /// <response code="400">User policy was not supplied.</response> + /// <response code="403">User policy update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns> + [HttpPost("{userId}/Policy")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult UpdateUserPolicy( + [FromRoute] Guid userId, + [FromBody] UserPolicy newPolicy) + { + if (newPolicy == null) + { + return BadRequest(); + } + + var user = _userManager.GetUserById(userId); + + // If removing admin access + if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) + { + if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) + { + return Forbid("There must be at least one user in the system with administrative access."); + } + } + + // If disabling + if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) + { + return Forbid("Administrators cannot be disabled."); + } + + // If disabling + if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) + { + if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) + { + return Forbid("There must be at least one enabled user in the system."); + } + + var currentToken = _authContext.GetAuthorizationInfo(Request).Token; + _sessionManager.RevokeUserTokens(user.Id, currentToken); + } + + _userManager.UpdatePolicy(userId, newPolicy); + + return NoContent(); + } + + /// <summary> + /// Updates a user configuration. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="userConfig">The new user configuration.</param> + /// <response code="204">User configuration updated.</response> + /// <response code="403">User configuration update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("{userId}/Configuration")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult UpdateUserConfiguration( + [FromRoute] Guid userId, + [FromBody] UserConfiguration userConfig) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) + { + return Forbid("User configuration update not allowed"); + } + + _userManager.UpdateConfiguration(userId, userConfig); + + return NoContent(); + } + + /// <summary> + /// Creates a user. + /// </summary> + /// <param name="request">The create user by name request body.</param> + /// <response code="200">User created.</response> + /// <returns>An <see cref="UserDto"/> of the new user.</returns> + [HttpPost("New")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<UserDto>> CreateUserByName([FromBody] CreateUserByName request) + { + var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false); + + // no need to authenticate password for new user + if (request.Password != null) + { + await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); + } + + var result = _userManager.GetUserDto(newUser, HttpContext.Connection.RemoteIpAddress.ToString()); + + return result; + } + + /// <summary> + /// Initiates the forgot password process for a local user. + /// </summary> + /// <param name="enteredUsername">The entered username.</param> + /// <response code="200">Password reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> + [HttpPost("ForgotPassword")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody] string? enteredUsername) + { + var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress) + || _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString()); + + var result = await _userManager.StartForgotPasswordProcess(enteredUsername, isLocal).ConfigureAwait(false); + + return result; + } + + /// <summary> + /// Redeems a forgot password pin. + /// </summary> + /// <param name="pin">The pin.</param> + /// <response code="200">Pin reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> + [HttpPost("ForgotPassword/Pin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string? pin) + { + var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false); + return result; + } + + private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + { + var users = _userManager.Users; + + if (isDisabled.HasValue) + { + users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value); + } + + if (isHidden.HasValue) + { + users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value); + } + + if (filterByDevice) + { + var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId; + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); + } + } + + if (filterByNetwork) + { + if (!_networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString())) + { + users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); + } + } + + var result = users + .OrderBy(u => u.Username) + .Select(i => _userManager.GetUserDto(i, HttpContext.Connection.RemoteIpAddress.ToString())); + + return result; + } + } +} diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs new file mode 100644 index 000000000..f55ff6f3d --- /dev/null +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// User library controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class UserLibraryController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataRepository; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserViewManager _userViewManager; + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="UserLibraryController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + public UserLibraryController( + IUserManager userManager, + IUserDataManager userDataRepository, + ILibraryManager libraryManager, + IDtoService dtoService, + IUserViewManager userViewManager, + IFileSystem fileSystem) + { + _userManager = userManager; + _userDataRepository = userDataRepository; + _libraryManager = libraryManager; + _dtoService = dtoService; + _userViewManager = userViewManager; + _fileSystem = fileSystem; + } + + /// <summary> + /// Gets an item from a user's library. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item returned.</response> + /// <returns>An <see cref="OkResult"/> containing the d item.</returns> + [HttpGet("Users/{userId}/Items/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute] Guid userId, [FromRoute] Guid itemId) + { + var user = _userManager.GetUserById(userId); + + var item = itemId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); + + var dtoOptions = new DtoOptions().AddClientFields(Request); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + /// <summary> + /// Gets the root folder from a user's library. + /// </summary> + /// <param name="userId">User id.</param> + /// <response code="200">Root folder returned.</response> + /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns> + [HttpGet("Users/{userId}/Items/Root")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetRootFolder([FromRoute] Guid userId) + { + var user = _userManager.GetUserById(userId); + var item = _libraryManager.GetUserRootFolder(); + var dtoOptions = new DtoOptions().AddClientFields(Request); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + /// <summary> + /// Gets intros to play before the main media item plays. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Intros returned.</response> + /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/Intros")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute] Guid userId, [FromRoute] Guid itemId) + { + var user = _userManager.GetUserById(userId); + + var item = itemId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); + var dtoOptions = new DtoOptions().AddClientFields(Request); + var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); + + return new QueryResult<BaseItemDto> + { + Items = dtos, + TotalRecordCount = dtos.Length + }; + } + + /// <summary> + /// Marks an item as a favorite. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item marked as favorite.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpPost("Users/{userId}/FavoriteItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute] Guid userId, [FromRoute] Guid itemId) + { + return MarkFavorite(userId, itemId, true); + } + + /// <summary> + /// Unmarks item as a favorite. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item unmarked as favorite.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute] Guid userId, [FromRoute] Guid itemId) + { + return MarkFavorite(userId, itemId, false); + } + + /// <summary> + /// Deletes a user's saved personal rating for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Personal rating removed.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpDelete("Users/{userId}/Items/{itemId}/Rating")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute] Guid userId, [FromRoute] Guid itemId) + { + return UpdateUserItemRatingInternal(userId, itemId, null); + } + + /// <summary> + /// Updates a user's rating for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param> + /// <response code="200">Item rating updated.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpPost("Users/{userId}/Items/{itemId}/Rating")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute] Guid userId, [FromRoute] Guid itemId, [FromQuery] bool? likes) + { + return UpdateUserItemRatingInternal(userId, itemId, likes); + } + + /// <summary> + /// Gets local trailers for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response> + /// <returns>The items local trailers.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute] Guid userId, [FromRoute] Guid itemId) + { + var user = _userManager.GetUserById(userId); + + var item = itemId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var dtosExtras = item.GetExtras(new[] { ExtraType.Trailer }) + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); + + if (item is IHasTrailers hasTrailers) + { + var trailers = hasTrailers.GetTrailers(); + var dtosTrailers = _dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item); + var allTrailers = new BaseItemDto[dtosExtras.Length + dtosTrailers.Count]; + dtosExtras.CopyTo(allTrailers, 0); + dtosTrailers.CopyTo(allTrailers, dtosExtras.Length); + return allTrailers; + } + + return dtosExtras; + } + + /// <summary> + /// Gets special features for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Special features returned.</response> + /// <returns>An <see cref="OkResult"/> containing the special features.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute] Guid userId, [FromRoute] Guid itemId) + { + var user = _userManager.GetUserById(userId); + + var item = itemId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + var dtoOptions = new DtoOptions().AddClientFields(Request); + + return Ok(item + .GetExtras(BaseItem.DisplayExtraTypes) + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); + } + + /// <summary> + /// Gets latest media. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> + /// <param name="isPlayed">Filter by items that are played, or not.</param> + /// <param name="enableImages">Optional. include image information in output.</param> + /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. include user data.</param> + /// <param name="limit">Return item limit.</param> + /// <param name="groupItems">Whether or not to group items into a parent container.</param> + /// <response code="200">Latest media returned.</response> + /// <returns>An <see cref="OkResult"/> containing the latest media.</returns> + [HttpGet("Users/{userId}/Items/Latest")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia( + [FromRoute] Guid userId, + [FromQuery] Guid? parentId, + [FromQuery] string? fields, + [FromQuery] string? includeItemTypes, + [FromQuery] bool? isPlayed, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] int limit = 20, + [FromQuery] bool groupItems = true) + { + var user = _userManager.GetUserById(userId); + + if (!isPlayed.HasValue) + { + if (user.HidePlayedInLatest) + { + isPlayed = false; + } + } + + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var list = _userViewManager.GetLatestItems( + new LatestItemsQuery + { + GroupItems = groupItems, + IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + IsPlayed = isPlayed, + Limit = limit, + ParentId = parentId ?? Guid.Empty, + UserId = userId, + }, dtoOptions); + + var dtos = list.Select(i => + { + var item = i.Item2[0]; + var childCount = 0; + + if (i.Item1 != null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum)) + { + item = i.Item1; + childCount = i.Item2.Count; + } + + var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user); + + dto.ChildCount = childCount; + + return dto; + }); + + return Ok(dtos); + } + + private async Task RefreshItemOnDemandIfNeeded(BaseItem item) + { + if (item is Person) + { + var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); + var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; + + if (!hasMetdata) + { + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = performFullRefresh + }; + + await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); + } + } + } + + /// <summary> + /// Marks the favorite. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param> + private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite) + { + var user = _userManager.GetUserById(userId); + + var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); + + // Get the user data for this item + var data = _userDataRepository.GetUserData(user, item); + + // Set favorite status + data.IsFavorite = isFavorite; + + _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + + return _userDataRepository.GetUserDataDto(item, user); + } + + /// <summary> + /// Updates the user item rating. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="likes">if set to <c>true</c> [likes].</param> + private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes) + { + var user = _userManager.GetUserById(userId); + + var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); + + // Get the user data for this item + var data = _userDataRepository.GetUserData(user, item); + + data.Likes = likes; + + _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + + return _userDataRepository.GetUserDataDto(item, user); + } + } +} diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs new file mode 100644 index 000000000..6df7cc779 --- /dev/null +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.UserViewDtos; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Library; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// User views controller. + /// </summary> + [Route("")] + public class UserViewsController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly IUserViewManager _userViewManager; + private readonly IDtoService _dtoService; + private readonly IAuthorizationContext _authContext; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="UserViewsController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public UserViewsController( + IUserManager userManager, + IUserViewManager userViewManager, + IDtoService dtoService, + IAuthorizationContext authContext, + ILibraryManager libraryManager) + { + _userManager = userManager; + _userViewManager = userViewManager; + _dtoService = dtoService; + _authContext = authContext; + _libraryManager = libraryManager; + } + + /// <summary> + /// Get user views. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param> + /// <param name="presetViews">Preset views.</param> + /// <param name="includeHidden">Whether or not to include hidden content.</param> + /// <response code="200">User views returned.</response> + /// <returns>An <see cref="OkResult"/> containing the user views.</returns> + [HttpGet("Users/{userId}/Views")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetUserViews( + [FromRoute] Guid userId, + [FromQuery] bool? includeExternalContent, + [FromQuery] string? presetViews, + [FromQuery] bool includeHidden = false) + { + var query = new UserViewQuery + { + UserId = userId, + IncludeHidden = includeHidden + }; + + if (includeExternalContent.HasValue) + { + query.IncludeExternalContent = includeExternalContent.Value; + } + + if (!string.IsNullOrWhiteSpace(presetViews)) + { + query.PresetViews = RequestHelpers.Split(presetViews, ',', true); + } + + var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty; + if (app.IndexOf("emby rt", StringComparison.OrdinalIgnoreCase) != -1) + { + query.PresetViews = new[] { CollectionType.Movies, CollectionType.TvShows }; + } + + var folders = _userViewManager.GetUserViews(query); + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var fields = dtoOptions.Fields.ToList(); + + fields.Add(ItemFields.PrimaryImageAspectRatio); + fields.Add(ItemFields.DisplayPreferencesId); + fields.Remove(ItemFields.BasicSyncInfo); + dtoOptions.Fields = fields.ToArray(); + + var user = _userManager.GetUserById(userId); + + var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) + .ToArray(); + + return new QueryResult<BaseItemDto> + { + Items = dtos, + TotalRecordCount = dtos.Length + }; + } + + /// <summary> + /// Get user view grouping options. + /// </summary> + /// <param name="userId">User id.</param> + /// <response code="200">User view grouping options returned.</response> + /// <response code="404">User not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the user view grouping options + /// or a <see cref="NotFoundResult"/> if user not found. + /// </returns> + [HttpGet("Users/{userId}/GroupingOptions")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute] Guid userId) + { + var user = _userManager.GetUserById(userId); + if (user == null) + { + return NotFound(); + } + + return Ok(_libraryManager.GetUserRootFolder() + .GetChildren(user, true) + .OfType<Folder>() + .Where(UserView.IsEligibleForGrouping) + .Select(i => new SpecialViewOptionDto + { + Name = i.Name, + Id = i.Id.ToString("N", CultureInfo.InvariantCulture) + }) + .OrderBy(i => i.Name)); + } + } +} diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs new file mode 100644 index 000000000..09a1c93e6 --- /dev/null +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -0,0 +1,81 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Attachments controller. + /// </summary> + [Route("Videos")] + public class VideoAttachmentsController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IAttachmentExtractor _attachmentExtractor; + + /// <summary> + /// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param> + public VideoAttachmentsController( + ILibraryManager libraryManager, + IAttachmentExtractor attachmentExtractor) + { + _libraryManager = libraryManager; + _attachmentExtractor = attachmentExtractor; + } + + /// <summary> + /// Get video attachment. + /// </summary> + /// <param name="videoId">Video ID.</param> + /// <param name="mediaSourceId">Media Source ID.</param> + /// <param name="index">Attachment Index.</param> + /// <response code="200">Attachment retrieved.</response> + /// <response code="404">Video or attachment not found.</response> + /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns> + [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<FileStreamResult>> GetAttachment( + [FromRoute, Required] Guid videoId, + [FromRoute, Required] string mediaSourceId, + [FromRoute, Required] int index) + { + try + { + var item = _libraryManager.GetItemById(videoId); + if (item == null) + { + return NotFound(); + } + + var (attachment, stream) = await _attachmentExtractor.GetAttachment( + item, + mediaSourceId, + index, + CancellationToken.None) + .ConfigureAwait(false); + + var contentType = string.IsNullOrWhiteSpace(attachment.MimeType) + ? MediaTypeNames.Application.Octet + : attachment.MimeType; + + return new FileStreamResult(stream, contentType); + } + catch (ResourceNotFoundException e) + { + return NotFound(e.Message); + } + } + } +} diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs new file mode 100644 index 000000000..76188f46d --- /dev/null +++ b/Jellyfin.Api/Controllers/VideoHlsController.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.PlaybackDtos; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The video hls controller. + /// </summary> + [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class VideoHlsController : BaseJellyfinApiController + { + private const string DefaultEncoderPreset = "superfast"; + private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; + + private readonly EncodingHelper _encodingHelper; + private readonly IDlnaManager _dlnaManager; + private readonly IAuthorizationContext _authContext; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IConfiguration _configuration; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ILogger<VideoHlsController> _logger; + private readonly EncodingOptions _encodingOptions; + + /// <summary> + /// Initializes a new instance of the <see cref="VideoHlsController"/> class. + /// </summary> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="userManger">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param> + /// <param name="logger">Instance of the <see cref="ILogger{VideoHlsController}"/>.</param> + public VideoHlsController( + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + IDlnaManager dlnaManager, + IUserManager userManger, + IAuthorizationContext authorizationContext, + ILibraryManager libraryManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + ILogger<VideoHlsController> logger) + { + _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); + + _dlnaManager = dlnaManager; + _authContext = authorizationContext; + _userManager = userManger; + _libraryManager = libraryManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _subtitleEncoder = subtitleEncoder; + _configuration = configuration; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _logger = logger; + _encodingOptions = serverConfigurationManager.GetEncodingOptions(); + } + + /// <summary> + /// Gets a hls live stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="maxWidth">Optional. The max width.</param> + /// <param name="maxHeight">Optional. The max height.</param> + /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param> + /// <response code="200">Hls live stream retrieved.</response> + /// <returns>A <see cref="FileResult"/> containing the hls file.</returns> + [HttpGet("Videos/{itemId}/live.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetLiveHlsStream( + [FromRoute] Guid itemId, + [FromQuery] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? enableSubtitlesInManifest) + { + VideoRequestDto streamingRequest = new VideoRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? true, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodingReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context, + StreamOptions = streamOptions, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true + }; + + var cancellationTokenSource = new CancellationTokenSource(); + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _fileSystem, + _subtitleEncoder, + _configuration, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + TranscodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + TranscodingJobDto? job = null; + var playlist = state.OutputFilePath; + + if (!System.IO.File.Exists(playlist)) + { + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist); + await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + try + { + if (!System.IO.File.Exists(playlist)) + { + // If the playlist doesn't already exist, startup ffmpeg + try + { + job = await _transcodingJobHelper.StartFfMpeg( + state, + playlist, + GetCommandLineArguments(playlist, state), + Request, + TranscodingJobType, + cancellationTokenSource) + .ConfigureAwait(false); + job.IsLiveOutput = true; + } + catch + { + state.Dispose(); + throw; + } + + minSegments = state.MinSegments; + if (minSegments > 0) + { + await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + } + finally + { + transcodingLock.Release(); + } + } + + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType); + + if (job != null) + { + _transcodingJobHelper.OnTranscodeEndRequest(job); + } + + var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength); + + return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); + } + + /// <summary> + /// Gets the command line arguments for ffmpeg. + /// </summary> + /// <param name="outputPath">The output path of the file.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <returns>The command line arguments as a string.</returns> + private string GetCommandLineArguments(string outputPath, StreamState state) + { + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); + var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); + var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts"; + var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format; + + var segmentFormat = format.TrimStart('.'); + if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) + { + segmentFormat = "mpegts"; + } + + var baseUrlParam = string.Format( + CultureInfo.InvariantCulture, + "\"hls{0}\"", + Path.GetFileNameWithoutExtension(outputPath)); + + return string.Format( + CultureInfo.InvariantCulture, + "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"", + inputModifier, + _encodingHelper.GetInputArgument(state, _encodingOptions), + threads, + _encodingHelper.GetMapArgs(state), + GetVideoArguments(state), + GetAudioArguments(state), + state.SegmentLength.ToString(CultureInfo.InvariantCulture), + string.Empty, + segmentFormat, + baseUrlParam, + outputPath, + outputTsArg) + .Trim(); + } + + /// <summary> + /// Gets the audio arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <returns>The command line arguments for audio transcoding.</returns> + private string GetAudioArguments(StreamState state) + { + var codec = _encodingHelper.GetAudioEncoder(state); + + if (EncodingHelper.IsCopyCodec(codec)) + { + return "-codec:a:0 copy"; + } + + var args = "-codec:a:0 " + codec; + + var channels = state.OutputAudioChannels; + + if (channels.HasValue) + { + args += " -ac " + channels.Value; + } + + var bitrate = state.OutputAudioBitrate; + + if (bitrate.HasValue) + { + args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); + } + + if (state.OutputAudioSampleRate.HasValue) + { + args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } + + args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); + + return args; + } + + /// <summary> + /// Gets the video arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <returns>The command line arguments for video transcoding.</returns> + private string GetVideoArguments(StreamState state) + { + if (!state.IsOutputVideo) + { + return string.Empty; + } + + var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + + var args = "-codec:v:0 " + codec; + + // if (state.EnableMpegtsM2TsMode) + // { + // args += " -mpegts_m2ts_mode 1"; + // } + + // See if we can save come cpu cycles by avoiding encoding + if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase)) + { + // if h264_mp4toannexb is ever added, do not use it for live tv + if (state.VideoStream != null && + !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) + { + string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); + if (!string.IsNullOrEmpty(bitStreamArgs)) + { + args += " " + bitStreamArgs; + } + } + } + else + { + var keyFrameArg = string.Format( + CultureInfo.InvariantCulture, + " -force_key_frames \"expr:gte(t,n_forced*{0})\"", + state.SegmentLength.ToString(CultureInfo.InvariantCulture)); + + var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + + args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg; + + // Add resolution params, if specified + if (!hasGraphicalSubs) + { + args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); + } + + // This is for internal graphical subs + if (hasGraphicalSubs) + { + args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); + } + } + + args += " -flags -global_header"; + + if (!string.IsNullOrEmpty(state.OutputVideoSync)) + { + args += " -vsync " + state.OutputVideoSync; + } + + args += _encodingHelper.GetOutputFFlags(state); + + return args; + } + } +} diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs new file mode 100644 index 000000000..fafa722c5 --- /dev/null +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -0,0 +1,528 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The videos controller. + /// </summary> + public class VideosController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly IDlnaManager _dlnaManager; + private readonly IAuthorizationContext _authContext; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IConfiguration _configuration; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly IHttpClientFactory _httpClientFactory; + + private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; + + /// <summary> + /// Initializes a new instance of the <see cref="VideosController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> + /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> + public VideosController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService, + IDlnaManager dlnaManager, + IAuthorizationContext authContext, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + IHttpClientFactory httpClientFactory) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + _dlnaManager = dlnaManager; + _authContext = authContext; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _subtitleEncoder = subtitleEncoder; + _configuration = configuration; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _httpClientFactory = httpClientFactory; + } + + /// <summary> + /// Gets additional parts for a video. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Additional parts returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns> + [HttpGet("{itemId}/AdditionalParts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute] Guid itemId, [FromQuery] Guid? userId) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + + var item = itemId.Equals(Guid.Empty) + ? (!userId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.RootFolder) + : _libraryManager.GetItemById(itemId); + + var dtoOptions = new DtoOptions(); + dtoOptions = dtoOptions.AddClientFields(Request); + + BaseItemDto[] items; + if (item is Video video) + { + items = video.GetAdditionalParts() + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) + .ToArray(); + } + else + { + items = Array.Empty<BaseItemDto>(); + } + + var result = new QueryResult<BaseItemDto> + { + Items = items, + TotalRecordCount = items.Length + }; + + return result; + } + + /// <summary> + /// Removes alternate video sources. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="204">Alternate sources deleted.</response> + /// <response code="404">Video not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns> + [HttpDelete("{itemId}/AlternateSources")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteAlternateSources([FromRoute] Guid itemId) + { + var video = (Video)_libraryManager.GetItemById(itemId); + + if (video == null) + { + return NotFound("The video either does not exist or the id does not belong to a video."); + } + + if (video.LinkedAlternateVersions.Length == 0) + { + video = (Video)_libraryManager.GetItemById(video.PrimaryVersionId); + } + + foreach (var link in video.GetLinkedAlternateVersions()) + { + link.SetPrimaryVersionId(null); + link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + + link.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + } + + video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + video.SetPrimaryVersionId(null); + video.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Merges videos into a single record. + /// </summary> + /// <param name="itemIds">Item id list. This allows multiple, comma delimited.</param> + /// <response code="204">Videos merged.</response> + /// <response code="400">Supply at least 2 video ids.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns> + [HttpPost("MergeVersions")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult MergeVersions([FromQuery, Required] string? itemIds) + { + var items = RequestHelpers.Split(itemIds, ',', true) + .Select(i => _libraryManager.GetItemById(i)) + .OfType<Video>() + .OrderBy(i => i.Id) + .ToList(); + + if (items.Count < 2) + { + return BadRequest("Please supply at least two videos to merge."); + } + + var videosWithVersions = items.Where(i => i.MediaSourceCount > 1).ToList(); + + var primaryVersion = videosWithVersions.FirstOrDefault(); + if (primaryVersion == null) + { + primaryVersion = items + .OrderBy(i => + { + if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) + { + return 1; + } + + return 0; + }) + .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0) + .First(); + } + + var list = primaryVersion.LinkedAlternateVersions.ToList(); + + foreach (var item in items.Where(i => i.Id != primaryVersion.Id)) + { + item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); + + item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + + list.Add(new LinkedChild + { + Path = item.Path, + ItemId = item.Id + }); + + foreach (var linkedItem in item.LinkedAlternateVersions) + { + if (!list.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) + { + list.Add(linkedItem); + } + } + + if (item.LinkedAlternateVersions.Length > 0) + { + item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + } + } + + primaryVersion.LinkedAlternateVersions = list.ToArray(); + primaryVersion.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Gets a video stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetVideoStream_2")] + [HttpGet("{itemId}/stream")] + [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStream_2")] + [HttpHead("{itemId}/stream", Name = "HeadVideoStream")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetVideoStream( + [FromRoute] Guid itemId, + [FromRoute] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new VideoRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? true, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodingReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context, + StreamOptions = streamOptions + }; + + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _fileSystem, + _subtitleEncoder, + _configuration, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + _transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + if (@static.HasValue && @static.Value && state.DirectStreamProvider != null) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); + + await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None) + { + AllowEndOfFile = false + }.WriteToAsync(Response.Body, CancellationToken.None) + .ConfigureAwait(false); + + // TODO (moved from MediaBrowser.Api): Don't hardcode contentType + return File(Response.Body, MimeTypes.GetMimeType("file.ts")!); + } + + // Static remote stream + if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); + + using var httpClient = _httpClientFactory.CreateClient(); + return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, httpClient).ConfigureAwait(false); + } + + if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File) + { + return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); + } + + var outputPath = state.OutputFilePath; + var outputPathExists = System.IO.File.Exists(outputPath); + + var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var isTranscodeCached = outputPathExists && transcodingJob != null; + + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, startTimeTicks, Request, _dlnaManager); + + // Static stream + if (@static.HasValue && @static.Value) + { + var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + + if (state.MediaSource.IsInfiniteStream) + { + await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None) + { + AllowEndOfFile = false + }.WriteToAsync(Response.Body, CancellationToken.None) + .ConfigureAwait(false); + + return File(Response.Body, contentType); + } + + return FileStreamResponseHelpers.GetStaticFileResult( + state.MediaPath, + contentType, + isHeadRequest, + this); + } + + // Need to start ffmpeg (because media can't be returned directly) + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + var ffmpegCommandLineArguments = encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast"); + return await FileStreamResponseHelpers.GetTranscodedFile( + state, + isHeadRequest, + this, + _transcodingJobHelper, + ffmpegCommandLineArguments, + Request, + _transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs new file mode 100644 index 000000000..eb91ac23e --- /dev/null +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Dto; +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; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Years controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class YearsController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + + /// <summary> + /// Initializes a new instance of the <see cref="YearsController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public YearsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } + + /// <summary> + /// Get years. + /// </summary> + /// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="sortBy">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.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="userId">User Id.</param> + /// <param name="recursive">Search recursively.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <response code="200">Year query returned.</response> + /// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetYears( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? sortOrder, + [FromQuery] string? parentId, + [FromQuery] string? fields, + [FromQuery] string? excludeItemTypes, + [FromQuery] string? includeItemTypes, + [FromQuery] string? mediaTypes, + [FromQuery] string? sortBy, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string? enableImageTypes, + [FromQuery] Guid? userId, + [FromQuery] bool recursive = true, + [FromQuery] bool? enableImages = true) + { + var dtoOptions = new DtoOptions() + .AddItemFields(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); + } + + IList<BaseItem> items; + + 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, + DtoOptions = dtoOptions + }; + + bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr); + + if (parentItem.IsFolder) + { + var folder = (Folder)parentItem; + + if (!userId.Equals(Guid.Empty)) + { + items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList(); + } + else + { + items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList(); + } + } + else + { + items = new[] { parentItem }.Where(Filter).ToList(); + } + + var extractedItems = GetAllItems(items); + + var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder)); + + var ibnItemsArray = filteredItems.ToList(); + + IEnumerable<BaseItem> ibnItems = ibnItemsArray; + + var result = new QueryResult<BaseItemDto> { TotalRecordCount = ibnItemsArray.Count }; + + if (startIndex.HasValue || limit.HasValue) + { + if (startIndex.HasValue) + { + ibnItems = ibnItems.Skip(startIndex.Value); + } + + if (limit.HasValue) + { + ibnItems = ibnItems.Take(limit.Value); + } + } + + var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>())); + + var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user)); + + result.Items = dtos.Where(i => i != null).ToArray(); + + return result; + } + + /// <summary> + /// Gets a year. + /// </summary> + /// <param name="year">The year.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Year returned.</response> + /// <response code="404">Year not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the year, + /// or a <see cref="NotFoundResult"/> if year not found. + /// </returns> + [HttpGet("{year}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<BaseItemDto> GetYear([FromRoute] int year, [FromQuery] Guid? userId) + { + var item = _libraryManager.GetYear(year); + if (item == null) + { + return NotFound(); + } + + var dtoOptions = new DtoOptions() + .AddClientFields(Request); + + if (userId.HasValue && !userId.Equals(Guid.Empty)) + { + var user = _userManager.GetUserById(userId.Value); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + return _dtoService.GetBaseItemDto(item, dtoOptions); + } + + private bool FilterItem(BaseItem f, IReadOnlyCollection<string> excludeItemTypes, IReadOnlyCollection<string> includeItemTypes, IReadOnlyCollection<string> mediaTypes) + { + // Exclude item types + if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + // Include item types + if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + // Include MediaTypes + if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items) + { + return items + .Select(i => i.ProductionYear ?? 0) + .Where(i => i > 0) + .Distinct() + .Select(year => _libraryManager.GetYear(year)); + } + } +} diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs new file mode 100644 index 000000000..e61e9c29d --- /dev/null +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -0,0 +1,162 @@ +using System; +using System.Linq; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Extensions +{ + /// <summary> + /// Dto Extensions. + /// </summary> + public static class DtoExtensions + { + /// <summary> + /// Add Dto Item fields. + /// </summary> + /// <remarks> + /// Converted from IHasItemFields. + /// Legacy order: 1. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="fields">Comma delimited string of fields.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string? fields) + { + if (string.IsNullOrEmpty(fields)) + { + dtoOptions.Fields = Array.Empty<ItemFields>(); + } + 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; + } + + /// <summary> + /// Add additional fields depending on client. + /// </summary> + /// <remarks> + /// Use in place of GetDtoOptions. + /// Legacy order: 2. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="request">Current request.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddClientFields( + this DtoOptions dtoOptions, HttpRequest request) + { + dtoOptions.Fields ??= Array.Empty<ItemFields>(); + + string? client = ClaimHelpers.GetClient(request.HttpContext.User); + + // No client in claim + if (string.IsNullOrEmpty(client)) + { + return dtoOptions; + } + + if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) + { + int oldLen = dtoOptions.Fields.Length; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.RecursiveItemCount; + dtoOptions.Fields = arr; + } + } + + if (!dtoOptions.ContainsField(ItemFields.ChildCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) + { + int oldLen = dtoOptions.Fields.Length; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.ChildCount; + dtoOptions.Fields = arr; + } + } + + return dtoOptions; + } + + /// <summary> + /// Add additional DtoOptions. + /// </summary> + /// <remarks> + /// Converted from IHasDtoOptions. + /// Legacy order: 3. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="enableImages">Enable images.</param> + /// <param name="enableUserData">Enable user data.</param> + /// <param name="imageTypeLimit">Image type limit.</param> + /// <param name="enableImageTypes">Enable image types.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddAdditionalDtoOptions( + this DtoOptions dtoOptions, + bool? enableImages, + bool? enableUserData, + int? imageTypeLimit, + string? enableImageTypes) + { + dtoOptions.EnableImages = enableImages ?? true; + + if (imageTypeLimit.HasValue) + { + dtoOptions.ImageTypeLimit = imageTypeLimit.Value; + } + + if (enableUserData.HasValue) + { + dtoOptions.EnableUserData = enableUserData.Value; + } + + if (!string.IsNullOrWhiteSpace(enableImageTypes)) + { + dtoOptions.ImageTypes = enableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true)) + .ToArray(); + } + + return dtoOptions; + } + + /// <summary> + /// Check if DtoOptions contains field. + /// </summary> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="field">Field to check.</param> + /// <returns>Field existence.</returns> + internal static bool ContainsField(this DtoOptions dtoOptions, ItemFields field) + => dtoOptions.Fields != null && dtoOptions.Fields.Contains(field); + } +} diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs new file mode 100644 index 000000000..df235ced2 --- /dev/null +++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using System.Security.Claims; +using Jellyfin.Api.Constants; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// Claim Helpers. + /// </summary> + public static class ClaimHelpers + { + /// <summary> + /// Get user id from claims. + /// </summary> + /// <param name="user">Current claims principal.</param> + /// <returns>User id.</returns> + public static Guid? GetUserId(in ClaimsPrincipal user) + { + var value = GetClaimValue(user, InternalClaimTypes.UserId); + return string.IsNullOrEmpty(value) + ? null + : (Guid?)Guid.Parse(value); + } + + /// <summary> + /// Get device id from claims. + /// </summary> + /// <param name="user">Current claims principal.</param> + /// <returns>Device id.</returns> + public static string? GetDeviceId(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.DeviceId); + + /// <summary> + /// Get device from claims. + /// </summary> + /// <param name="user">Current claims principal.</param> + /// <returns>Device.</returns> + public static string? GetDevice(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.Device); + + /// <summary> + /// Get client from claims. + /// </summary> + /// <param name="user">Current claims principal.</param> + /// <returns>Client.</returns> + public static string? GetClient(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.Client); + + /// <summary> + /// Get version from claims. + /// </summary> + /// <param name="user">Current claims principal.</param> + /// <returns>Version.</returns> + public static string? GetVersion(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.Version); + + /// <summary> + /// Get token from claims. + /// </summary> + /// <param name="user">Current claims principal.</param> + /// <returns>Token.</returns> + public static string? GetToken(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.Token); + + private static string? GetClaimValue(in ClaimsPrincipal user, string name) + { + return user?.Identities + .SelectMany(c => c.Claims) + .Where(claim => claim.Type.Equals(name, StringComparison.OrdinalIgnoreCase)) + .Select(claim => claim.Value) + .FirstOrDefault(); + } + } +} diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs new file mode 100644 index 000000000..a463783e0 --- /dev/null +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -0,0 +1,138 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.PlaybackDtos; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// The stream response helpers. + /// </summary> + public static class FileStreamResponseHelpers + { + /// <summary> + /// Returns a static file from a remote source. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param> + /// <param name="controller">The <see cref="ControllerBase"/> managing the response.</param> + /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param> + /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns> + public static async Task<ActionResult> GetStaticRemoteStreamResult( + StreamState state, + bool isHeadRequest, + ControllerBase controller, + HttpClient httpClient) + { + if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) + { + httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent); + } + + using var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType.ToString(); + + controller.Response.Headers[HeaderNames.AcceptRanges] = "none"; + + if (isHeadRequest) + { + return controller.File(Array.Empty<byte>(), contentType); + } + + return controller.File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType); + } + + /// <summary> + /// Returns a static file from the server. + /// </summary> + /// <param name="path">The path to the file.</param> + /// <param name="contentType">The content type of the file.</param> + /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param> + /// <param name="controller">The <see cref="ControllerBase"/> managing the response.</param> + /// <returns>An <see cref="ActionResult"/> the file.</returns> + public static ActionResult GetStaticFileResult( + string path, + string contentType, + bool isHeadRequest, + ControllerBase controller) + { + controller.Response.ContentType = contentType; + + // if the request is a head request, return a NoContent result with the same headers as it would with a GET request + if (isHeadRequest) + { + return controller.NoContent(); + } + + return controller.PhysicalFile(path, contentType); + } + + /// <summary> + /// Returns a transcoded file from the server. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param> + /// <param name="controller">The <see cref="ControllerBase"/> managing the response.</param> + /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param> + /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param> + /// <param name="request">The <see cref="HttpRequest"/> starting the transcoding.</param> + /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> + /// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param> + /// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns> + public static async Task<ActionResult> GetTranscodedFile( + StreamState state, + bool isHeadRequest, + ControllerBase controller, + TranscodingJobHelper transcodingJobHelper, + string ffmpegCommandLineArguments, + HttpRequest request, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource) + { + // Use the command line args with a dummy playlist path + var outputPath = state.OutputFilePath; + + controller.Response.Headers[HeaderNames.AcceptRanges] = "none"; + + var contentType = state.GetMimeType(outputPath); + + // Headers only + if (isHeadRequest) + { + return controller.File(Array.Empty<byte>(), contentType); + } + + var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath); + await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + try + { + TranscodingJobDto? job; + if (!File.Exists(outputPath)) + { + job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); + } + else + { + job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); + state.Dispose(); + } + + var memoryStream = new MemoryStream(); + await new ProgressiveFileCopier(outputPath, job, transcodingJobHelper, CancellationToken.None).WriteToAsync(memoryStream, CancellationToken.None).ConfigureAwait(false); + memoryStream.Position = 0; + return controller.File(memoryStream, contentType); + } + finally + { + transcodingLock.Release(); + } + } + } +} diff --git a/MediaBrowser.Api/Playback/Hls/HlsCodecStringFactory.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index 3bbb77a65..95f1906ef 100644 --- a/MediaBrowser.Api/Playback/Hls/HlsCodecStringFactory.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -1,15 +1,14 @@ -using System; +using System; +using System.Globalization; using System.Text; - -namespace MediaBrowser.Api.Playback +namespace Jellyfin.Api.Helpers { /// <summary> - /// Get various codec strings for use in HLS playlists. + /// Hls Codec string helpers. /// </summary> - static class HlsCodecStringFactory + public static class HlsCodecStringHelpers { - /// <summary> /// Gets a MP3 codec string. /// </summary> @@ -69,7 +68,7 @@ namespace MediaBrowser.Api.Playback result.Append(".4240"); } - string levelHex = level.ToString("X2"); + string levelHex = level.ToString("X2", CultureInfo.InvariantCulture); result.Append(levelHex); return result.ToString(); diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs new file mode 100644 index 000000000..242496697 --- /dev/null +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -0,0 +1,95 @@ +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// The hls helpers. + /// </summary> + public static class HlsHelpers + { + /// <summary> + /// Waits for a minimum number of segments to be available. + /// </summary> + /// <param name="playlist">The playlist string.</param> + /// <param name="segmentCount">The segment count.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> + /// <returns>A <see cref="Task"/> indicating the waiting process.</returns> + public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, CancellationToken cancellationToken) + { + logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written + var fileStream = new FileStream( + playlist, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + IODefaults.FileStreamBufferSize, + FileOptions.SequentialScan); + await using (fileStream.ConfigureAwait(false)) + { + using var reader = new StreamReader(fileStream); + var count = 0; + + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync().ConfigureAwait(false); + + if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) + { + count++; + if (count >= segmentCount) + { + logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist); + return; + } + } + } + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + catch (IOException) + { + // May get an error if the file is locked + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + + /// <summary> + /// Gets the hls playlist text. + /// </summary> + /// <param name="path">The path to the playlist file.</param> + /// <param name="segmentLength">The segment length.</param> + /// <returns>The playlist text as a string.</returns> + public static string GetLivePlaylistText(string path, int segmentLength) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + + var text = reader.ReadToEnd(); + + text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture); + + var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture); + + text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); + // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); + + return text; + } + } +} diff --git a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs new file mode 100644 index 000000000..432df9708 --- /dev/null +++ b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs @@ -0,0 +1,175 @@ +using System; +using System.Buffers; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// Progressive file copier. + /// </summary> + public class ProgressiveFileCopier + { + private readonly TranscodingJobDto? _job; + private readonly string? _path; + private readonly CancellationToken _cancellationToken; + private readonly IDirectStreamProvider? _directStreamProvider; + private readonly TranscodingJobHelper _transcodingJobHelper; + private long _bytesWritten; + + /// <summary> + /// Initializes a new instance of the <see cref="ProgressiveFileCopier"/> class. + /// </summary> + /// <param name="path">The path to copy from.</param> + /// <param name="job">The transcoding job.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/>.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public ProgressiveFileCopier(string path, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, CancellationToken cancellationToken) + { + _path = path; + _job = job; + _cancellationToken = cancellationToken; + _transcodingJobHelper = transcodingJobHelper; + } + + /// <summary> + /// Initializes a new instance of the <see cref="ProgressiveFileCopier"/> class. + /// </summary> + /// <param name="directStreamProvider">Instance of the <see cref="IDirectStreamProvider"/> interface.</param> + /// <param name="job">The transcoding job.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/>.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public ProgressiveFileCopier(IDirectStreamProvider directStreamProvider, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, CancellationToken cancellationToken) + { + _directStreamProvider = directStreamProvider; + _job = job; + _cancellationToken = cancellationToken; + _transcodingJobHelper = transcodingJobHelper; + } + + /// <summary> + /// Gets or sets a value indicating whether allow read end of file. + /// </summary> + public bool AllowEndOfFile { get; set; } = true; + + /// <summary> + /// Gets or sets copy start position. + /// </summary> + public long StartPosition { get; set; } + + /// <summary> + /// Write source stream to output. + /// </summary> + /// <param name="outputStream">Output stream.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>A <see cref="Task"/>.</returns> + public async Task WriteToAsync(Stream outputStream, CancellationToken cancellationToken) + { + cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken).Token; + + try + { + if (_directStreamProvider != null) + { + await _directStreamProvider.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + return; + } + + var fileOptions = FileOptions.SequentialScan; + var 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; + } + + await using var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); + + var eofCount = 0; + const int EmptyReadLimit = 20; + if (StartPosition > 0) + { + inputStream.Position = StartPosition; + } + + while (eofCount < EmptyReadLimit || !AllowEndOfFile) + { + var bytesRead = await CopyToInternalAsync(inputStream, outputStream, allowAsyncFileRead, cancellationToken).ConfigureAwait(false); + + if (bytesRead == 0) + { + if (_job == null || _job.HasExited) + { + eofCount++; + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + else + { + eofCount = 0; + } + } + } + finally + { + if (_job != null) + { + _transcodingJobHelper.OnTranscodeEndRequest(_job); + } + } + } + + private async Task<int> CopyToInternalAsync(Stream source, Stream destination, bool readAsync, CancellationToken cancellationToken) + { + var array = ArrayPool<byte>.Shared.Rent(IODefaults.CopyToBufferSize); + int bytesRead; + int totalBytesRead = 0; + + if (readAsync) + { + bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false); + } + else + { + bytesRead = source.Read(array, 0, array.Length); + } + + while (bytesRead != 0) + { + var bytesToWrite = bytesRead; + + if (bytesToWrite > 0) + { + await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); + + _bytesWritten += bytesRead; + totalBytesRead += bytesRead; + + if (_job != null) + { + _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); + } + } + + if (readAsync) + { + bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false); + } + else + { + bytesRead = source.Read(array, 0, array.Length); + } + } + + return totalBytesRead; + } + } +} diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs new file mode 100644 index 000000000..fbaa69270 --- /dev/null +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// Request Extensions. + /// </summary> + public static class RequestHelpers + { + /// <summary> + /// Get Order By. + /// </summary> + /// <param name="sortBy">Sort By. Comma delimited string.</param> + /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param> + /// <returns>Order By.</returns> + public static ValueTuple<string, SortOrder>[] GetOrderBy(string? sortBy, string? requestedSortOrder) + { + var val = sortBy; + + if (string.IsNullOrEmpty(val)) + { + return Array.Empty<ValueTuple<string, SortOrder>>(); + } + + var vals = val.Split(','); + if (string.IsNullOrWhiteSpace(requestedSortOrder)) + { + requestedSortOrder = "Ascending"; + } + + var sortOrders = requestedSortOrder.Split(','); + + var result = new ValueTuple<string, SortOrder>[vals.Length]; + + for (var i = 0; i < vals.Length; i++) + { + var sortOrderIndex = sortOrders.Length > i ? i : 0; + + var sortOrderValue = sortOrders.Length > sortOrderIndex ? sortOrders[sortOrderIndex] : null; + var sortOrder = string.Equals(sortOrderValue, "Descending", StringComparison.OrdinalIgnoreCase) + ? SortOrder.Descending + : SortOrder.Ascending; + + result[i] = new ValueTuple<string, SortOrder>(vals[i], sortOrder); + } + + return result; + } + + /// <summary> + /// Get parsed filters. + /// </summary> + /// <param name="filters">The filters.</param> + /// <returns>Item filters.</returns> + public static IEnumerable<ItemFilter> GetFilters(string? filters) + { + return string.IsNullOrEmpty(filters) + ? Array.Empty<ItemFilter>() + : filters.Split(',').Select(v => Enum.Parse<ItemFilter>(v, true)); + } + + /// <summary> + /// Splits a string at a separating character into an array of substrings. + /// </summary> + /// <param name="value">The string to split.</param> + /// <param name="separator">The char that separates the substrings.</param> + /// <param name="removeEmpty">Option to remove empty substrings from the array.</param> + /// <returns>An array of the substrings.</returns> + internal static string[] Split(string? value, char separator, bool removeEmpty) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Array.Empty<string>(); + } + + return removeEmpty + ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries) + : value.Split(separator); + } + + /// <summary> + /// Checks if the user can update an entry. + /// </summary> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="requestContext">The <see cref="HttpRequest"/>.</param> + /// <param name="userId">The user id.</param> + /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param> + /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns> + internal static bool AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences) + { + var auth = authContext.GetAuthorizationInfo(requestContext); + + var authenticatedUser = auth.User; + + // If they're going to update the record of another user, they must be an administrator + if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator)) + || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess)) + { + return false; + } + + return true; + } + + internal static SessionInfo GetSession(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request) + { + var authorization = authContext.GetAuthorizationInfo(request); + var user = authorization.User; + var session = sessionManager.LogSessionActivity( + authorization.Client, + authorization.Version, + authorization.DeviceId, + authorization.Device, + request.HttpContext.Connection.RemoteIpAddress.ToString(), + user); + + if (session == null) + { + throw new ArgumentException("Session not found."); + } + + return session; + } + + /// <summary> + /// Get Guid array from string. + /// </summary> + /// <param name="value">String value.</param> + /// <returns>Guid array.</returns> + internal static Guid[] GetGuids(string? value) + { + if (value == null) + { + return Array.Empty<Guid>(); + } + + return Split(value, ',', true) + .Select(i => new Guid(i)) + .ToArray(); + } + + /// <summary> + /// Gets the item fields. + /// </summary> + /// <param name="fields">The fields string.</param> + /// <returns>IEnumerable{ItemFields}.</returns> + internal static ItemFields[] GetItemFields(string? fields) + { + if (string.IsNullOrEmpty(fields)) + { + return Array.Empty<ItemFields>(); + } + + return Split(fields, ',', true) + .Select(v => + { + if (Enum.TryParse(v, true, out ItemFields value)) + { + return (ItemFields?)value; + } + + return null; + }).Where(i => i.HasValue) + .Select(i => i!.Value) + .ToArray(); + } + + internal static IPAddress NormalizeIp(IPAddress ip) + { + return ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip; + } + } +} diff --git a/MediaBrowser.Api/SimilarItemsHelper.cs b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs index 84abf7b8d..b922e76cf 100644 --- a/MediaBrowser.Api/SimilarItemsHelper.cs +++ b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs @@ -1,95 +1,48 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -namespace MediaBrowser.Api +namespace Jellyfin.Api.Helpers { /// <summary> - /// Class BaseGetSimilarItemsFromItem. - /// </summary> - public class BaseGetSimilarItemsFromItem : BaseGetSimilarItems - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - public string ExcludeArtistIds { get; set; } - } - - public class BaseGetSimilarItems : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - } - - /// <summary> - /// Class SimilarItemsHelper. + /// The similar items helper class. /// </summary> public static class SimilarItemsHelper { - internal static QueryResult<BaseItemDto> GetSimilarItemsResult(DtoOptions dtoOptions, IUserManager userManager, IItemRepository itemRepository, ILibraryManager libraryManager, IUserDataManager userDataRepository, IDtoService dtoService, BaseGetSimilarItemsFromItem request, Type[] includeTypes, Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) + internal static QueryResult<BaseItemDto> GetSimilarItemsResult( + DtoOptions dtoOptions, + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService, + Guid? userId, + string id, + string? excludeArtistIds, + int? limit, + Type[] includeTypes, + Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) { - var user = !request.UserId.Equals(Guid.Empty) ? userManager.GetUserById(request.UserId) : null; + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? userManager.GetUserById(userId.Value) + : null; - var item = string.IsNullOrEmpty(request.Id) ? - (!request.UserId.Equals(Guid.Empty) ? libraryManager.GetUserRootFolder() : - libraryManager.RootFolder) : libraryManager.GetItemById(request.Id); + 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 + DtoOptions = dtoOptions, + ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds) }; - // ExcludeArtistIds - if (!string.IsNullOrEmpty(request.ExcludeArtistIds)) - { - query.ExcludeArtistIds = BaseApiService.GetGuids(request.ExcludeArtistIds); - } - var inputItems = libraryManager.GetItemList(query); var items = GetSimilaritems(item, libraryManager, inputItems, getSimilarityScore) @@ -97,9 +50,9 @@ namespace MediaBrowser.Api var returnItems = items; - if (request.Limit.HasValue) + if (limit.HasValue) { - returnItems = returnItems.Take(request.Limit.Value).ToList(); + returnItems = returnItems.Take(limit.Value).ToList(); } var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); @@ -107,7 +60,6 @@ namespace MediaBrowser.Api return new QueryResult<BaseItemDto> { Items = dtos, - TotalRecordCount = items.Count }; } @@ -120,7 +72,11 @@ namespace MediaBrowser.Api /// <param name="inputItems">The input items.</param> /// <param name="getSimilarityScore">The get similarity score.</param> /// <returns>IEnumerable{BaseItem}.</returns> - internal static IEnumerable<BaseItem> GetSimilaritems(BaseItem item, ILibraryManager libraryManager, IEnumerable<BaseItem> inputItems, Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) + private static IEnumerable<BaseItem> GetSimilaritems( + BaseItem item, + ILibraryManager libraryManager, + IEnumerable<BaseItem> inputItems, + Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) { var itemId = item.Id; inputItems = inputItems.Where(i => i.Id != itemId); diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs new file mode 100644 index 000000000..b12590080 --- /dev/null +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -0,0 +1,758 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// The streaming helpers. + /// </summary> + public static class StreamingHelpers + { + /// <summary> + /// Gets the current streaming state. + /// </summary> + /// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param> + /// <param name="httpRequest">The <see cref="HttpRequest"/>.</param> + /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param> + /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> + /// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns> + public static async Task<StreamState> GetStreamingState( + StreamingRequestDto streamingRequest, + HttpRequest httpRequest, + IAuthorizationContext authorizationContext, + IMediaSourceManager mediaSourceManager, + IUserManager userManager, + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + IDlnaManager dlnaManager, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + TranscodingJobType transcodingJobType, + CancellationToken cancellationToken) + { + EncodingHelper encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); + // Parse the DLNA time seek header + if (!streamingRequest.StartTimeTicks.HasValue) + { + var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"]; + + streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString()); + } + + if (!string.IsNullOrWhiteSpace(streamingRequest.Params)) + { + ParseParams(streamingRequest); + } + + streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query); + + var url = httpRequest.Path.Value.Split('.').Last(); + + if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) + { + streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url); + } + + var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) || + string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase); + + var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper) + { + Request = streamingRequest, + RequestedUrl = url, + UserAgent = httpRequest.Headers[HeaderNames.UserAgent], + EnableDlnaHeaders = enableDlnaHeaders + }; + + var auth = authorizationContext.GetAuthorizationInfo(httpRequest); + if (!auth.UserId.Equals(Guid.Empty)) + { + state.User = userManager.GetUserById(auth.UserId); + } + + if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec)) + { + state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); + state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); + } + + if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec)) + { + state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); + state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToAudioCodec(i)) + ?? state.SupportedAudioCodecs.FirstOrDefault(); + } + + if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec)) + { + state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); + state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToSubtitleCodec(i)) + ?? state.SupportedSubtitleCodecs.FirstOrDefault(); + } + + var item = libraryManager.GetItemById(streamingRequest.Id); + + state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); + + MediaSourceInfo? mediaSource = null; + if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)) + { + var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId) + ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId) + : null; + + if (currentJob != null) + { + mediaSource = currentJob.MediaSource; + } + + if (mediaSource == null) + { + var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false); + + mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId) + ? mediaSources[0] + : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.InvariantCulture)); + + if (mediaSource == null && Guid.Parse(streamingRequest.MediaSourceId) == streamingRequest.Id) + { + mediaSource = mediaSources[0]; + } + } + } + else + { + var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false); + mediaSource = liveStreamInfo.Item1; + state.DirectStreamProvider = liveStreamInfo.Item2; + } + + encodingHelper.AttachMediaSourceInfo(state, mediaSource, url); + + string? containerInternal = Path.GetExtension(state.RequestedUrl); + + if (string.IsNullOrEmpty(streamingRequest.Container)) + { + containerInternal = streamingRequest.Container; + } + + if (string.IsNullOrEmpty(containerInternal)) + { + containerInternal = streamingRequest.Static ? + StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, state.MediaPath, null, DlnaProfileType.Audio) + : GetOutputFileExtension(state); + } + + state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); + + state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream); + + state.OutputAudioCodec = streamingRequest.AudioCodec; + + state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); + + if (state.VideoRequest != null) + { + state.OutputVideoCodec = state.Request.VideoCodec; + state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); + + encodingHelper.TryStreamCopy(state); + + if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + var resolution = ResolutionNormalizer.Normalize( + state.VideoStream?.BitRate, + state.VideoStream?.Width, + state.VideoStream?.Height, + state.OutputVideoBitrate.Value, + state.VideoStream?.Codec, + state.OutputVideoCodec, + state.VideoRequest.MaxWidth, + state.VideoRequest.MaxHeight); + + state.VideoRequest.MaxWidth = resolution.MaxWidth; + state.VideoRequest.MaxHeight = resolution.MaxHeight; + } + } + + ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static); + + var ext = string.IsNullOrWhiteSpace(state.OutputContainer) + ? GetOutputFileExtension(state) + : ('.' + state.OutputContainer); + + state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); + + return state; + } + + /// <summary> + /// Adds the dlna headers. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="responseHeaders">The response headers.</param> + /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + /// <param name="request">The <see cref="HttpRequest"/>.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + public static void AddDlnaHeaders( + StreamState state, + IHeaderDictionary responseHeaders, + bool isStaticallyStreamed, + long? startTimeTicks, + HttpRequest request, + IDlnaManager dlnaManager) + { + if (!state.EnableDlnaHeaders) + { + return; + } + + var profile = state.DeviceProfile; + + StringValues transferMode = request.Headers["transferMode.dlna.org"]; + responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString()); + responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*"); + + if (state.RunTimeTicks.HasValue) + { + if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase)) + { + var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds; + responseHeaders.Add("MediaInfo.sec", string.Format( + CultureInfo.InvariantCulture, + "SEC_Duration={0};", + Convert.ToInt32(ms))); + } + + if (!isStaticallyStreamed && profile != null) + { + AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks); + } + } + + if (profile == null) + { + profile = dlnaManager.GetDefaultProfile(); + } + + var audioCodec = state.ActualOutputAudioCodec; + + if (!state.IsVideoRequest) + { + responseHeaders.Add("contentFeatures.dlna.org", new ContentFeatureBuilder(profile).BuildAudioHeader( + state.OutputContainer, + audioCodec, + state.OutputAudioBitrate, + state.OutputAudioSampleRate, + state.OutputAudioChannels, + state.OutputAudioBitDepth, + isStaticallyStreamed, + state.RunTimeTicks, + state.TranscodeSeekInfo)); + } + else + { + var videoCodec = state.ActualOutputVideoCodec; + + responseHeaders.Add( + "contentFeatures.dlna.org", + new ContentFeatureBuilder(profile).BuildVideoHeader(state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty); + } + } + + /// <summary> + /// Parses the time seek header. + /// </summary> + /// <param name="value">The time seek header string.</param> + /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns> + private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value) + { + if (value.IsEmpty) + { + return null; + } + + const string npt = "npt="; + if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid timeseek header"); + } + + var index = value.IndexOf('-'); + value = index == -1 + ? value.Slice(npt.Length) + : value.Slice(npt.Length, index - npt.Length); + if (value.IndexOf(':') == -1) + { + // Parses npt times in the format of '417.33' + if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) + { + return TimeSpan.FromSeconds(seconds).Ticks; + } + + throw new ArgumentException("Invalid timeseek header"); + } + + try + { + // Parses npt times in the format of '10:19:25.7' + return TimeSpan.Parse(value).Ticks; + } + catch + { + throw new ArgumentException("Invalid timeseek header"); + } + } + + /// <summary> + /// Parses query parameters as StreamOptions. + /// </summary> + /// <param name="queryString">The query string.</param> + /// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns> + private static Dictionary<string, string> ParseStreamOptions(IQueryCollection queryString) + { + Dictionary<string, string> streamOptions = new Dictionary<string, string>(); + foreach (var param in queryString) + { + if (char.IsLower(param.Key[0])) + { + // This was probably not parsed initially and should be a StreamOptions + // or the generated URL should correctly serialize it + // TODO: This should be incorporated either in the lower framework for parsing requests + streamOptions[param.Key] = param.Value; + } + } + + return streamOptions; + } + + /// <summary> + /// Adds the dlna time seek headers to the response. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks) + { + var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); + var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); + + responseHeaders.Add("TimeSeekRange.dlna.org", string.Format( + CultureInfo.InvariantCulture, + "npt={0}-{1}/{1}", + startSeconds, + runtimeSeconds)); + responseHeaders.Add("X-AvailableSeekRange", string.Format( + CultureInfo.InvariantCulture, + "1 npt={0}-{1}", + startSeconds, + runtimeSeconds)); + } + + /// <summary> + /// Gets the output file extension. + /// </summary> + /// <param name="state">The state.</param> + /// <returns>System.String.</returns> + private static string? GetOutputFileExtension(StreamState state) + { + var ext = Path.GetExtension(state.RequestedUrl); + + if (!string.IsNullOrEmpty(ext)) + { + return ext; + } + + // Try to infer based on the desired video codec + if (state.IsVideoRequest) + { + var videoCodec = state.Request.VideoCodec; + + if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) || + string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase)) + { + return ".ts"; + } + + if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase)) + { + return ".ogv"; + } + + if (string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) + { + return ".webm"; + } + + if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) + { + return ".asf"; + } + } + + // Try to infer based on the desired audio codec + if (!state.IsVideoRequest) + { + var audioCodec = state.Request.AudioCodec; + + if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".aac"; + } + + if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".mp3"; + } + + if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".ogg"; + } + + if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".wma"; + } + } + + return null; + } + + /// <summary> + /// Gets the output file path for transcoding. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="outputFileExtension">The file extension of the output file.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <returns>The complete file path, including the folder, for the transcoding file.</returns> + private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId) + { + var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}"; + + var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture); + var ext = outputFileExtension?.ToLowerInvariant(); + var folder = serverConfigurationManager.GetTranscodePath(); + + return Path.Combine(folder, filename + ext); + } + + private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static) + { + var headers = request.Headers; + + if (!string.IsNullOrWhiteSpace(deviceProfileId)) + { + state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId); + } + else if (!string.IsNullOrWhiteSpace(deviceProfileId)) + { + var caps = deviceManager.GetCapabilities(deviceProfileId); + + state.DeviceProfile = caps == null ? dlnaManager.GetProfile(headers) : caps.DeviceProfile; + } + + var profile = state.DeviceProfile; + + if (profile == null) + { + // Don't use settings from the default profile. + // Only use a specific profile if it was requested. + return; + } + + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + + var mediaProfile = !state.IsVideoRequest + ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth) + : profile.GetVideoMediaProfile( + state.OutputContainer, + audioCodec, + videoCodec, + state.OutputWidth, + state.OutputHeight, + state.TargetVideoBitDepth, + state.OutputVideoBitrate, + state.TargetVideoProfile, + state.TargetVideoLevel, + state.TargetFramerate, + state.TargetPacketLength, + state.TargetTimestamp, + state.IsTargetAnamorphic, + state.IsTargetInterlaced, + state.TargetRefFrames, + state.TargetVideoStreamCount, + state.TargetAudioStreamCount, + state.TargetVideoCodecTag, + state.IsTargetAVC); + + if (mediaProfile != null) + { + state.MimeType = mediaProfile.MimeType; + } + + if (!(@static.HasValue && @static.Value)) + { + var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec); + + if (transcodingProfile != null) + { + state.EstimateContentLength = transcodingProfile.EstimateContentLength; + // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; + state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; + + if (state.VideoRequest != null) + { + state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps; + state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; + } + } + } + } + + /// <summary> + /// Parses the parameters. + /// </summary> + /// <param name="request">The request.</param> + private static void ParseParams(StreamingRequestDto request) + { + if (string.IsNullOrEmpty(request.Params)) + { + return; + } + + var vals = request.Params.Split(';'); + + var videoRequest = request as VideoRequestDto; + + for (var i = 0; i < vals.Length; i++) + { + var val = vals[i]; + + if (string.IsNullOrWhiteSpace(val)) + { + continue; + } + + switch (i) + { + case 0: + request.DeviceProfileId = val; + break; + case 1: + request.DeviceId = val; + break; + case 2: + request.MediaSourceId = val; + break; + case 3: + request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + break; + case 4: + if (videoRequest != null) + { + videoRequest.VideoCodec = val; + } + + break; + case 5: + request.AudioCodec = val; + break; + case 6: + if (videoRequest != null) + { + videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 7: + if (videoRequest != null) + { + videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 8: + if (videoRequest != null) + { + videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 9: + request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture); + break; + case 10: + request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); + break; + case 11: + if (videoRequest != null) + { + videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 12: + if (videoRequest != null) + { + videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 13: + if (videoRequest != null) + { + videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 14: + request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); + break; + case 15: + if (videoRequest != null) + { + videoRequest.Level = val; + } + + break; + case 16: + if (videoRequest != null) + { + videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 17: + if (videoRequest != null) + { + videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 18: + if (videoRequest != null) + { + videoRequest.Profile = val; + } + + break; + case 19: + // cabac no longer used + break; + case 20: + request.PlaySessionId = val; + break; + case 21: + // api_key + break; + case 22: + request.LiveStreamId = val; + break; + case 23: + // Duplicating ItemId because of MediaMonkey + break; + case 24: + if (videoRequest != null) + { + videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 25: + if (!string.IsNullOrWhiteSpace(val) && videoRequest != null) + { + if (Enum.TryParse(val, out SubtitleDeliveryMethod method)) + { + videoRequest.SubtitleMethod = method; + } + } + + break; + case 26: + request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); + break; + case 27: + if (videoRequest != null) + { + videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 28: + request.Tag = val; + break; + case 29: + if (videoRequest != null) + { + videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 30: + request.SubtitleCodec = val; + break; + case 31: + if (videoRequest != null) + { + videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 32: + if (videoRequest != null) + { + videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 33: + request.TranscodeReasons = val; + break; + } + } + } + } +} diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs new file mode 100644 index 000000000..67e450372 --- /dev/null +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -0,0 +1,886 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.PlaybackDtos; +using Jellyfin.Api.Models.StreamingDtos; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// Transcoding job helpers. + /// </summary> + public class TranscodingJobHelper : IDisposable + { + /// <summary> + /// The active transcoding jobs. + /// </summary> + private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>(); + + /// <summary> + /// The transcoding locks. + /// </summary> + private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>(); + + private readonly IAuthorizationContext _authorizationContext; + private readonly EncodingHelper _encodingHelper; + private readonly IFileSystem _fileSystem; + private readonly IIsoManager _isoManager; + private readonly ILogger<TranscodingJobHelper> _logger; + private readonly IMediaEncoder _mediaEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ISessionManager _sessionManager; + private readonly ILoggerFactory _loggerFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="isoManager">Instance of the <see cref="IIsoManager"/> interface.</param> + /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public TranscodingJobHelper( + ILogger<TranscodingJobHelper> logger, + IMediaSourceManager mediaSourceManager, + IFileSystem fileSystem, + IMediaEncoder mediaEncoder, + IServerConfigurationManager serverConfigurationManager, + ISessionManager sessionManager, + IAuthorizationContext authorizationContext, + IIsoManager isoManager, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + ILoggerFactory loggerFactory) + { + _logger = logger; + _mediaSourceManager = mediaSourceManager; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + _serverConfigurationManager = serverConfigurationManager; + _sessionManager = sessionManager; + _authorizationContext = authorizationContext; + _isoManager = isoManager; + _loggerFactory = loggerFactory; + _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); + + DeleteEncodedMediaCache(); + + sessionManager!.PlaybackProgress += OnPlaybackProgress; + sessionManager!.PlaybackStart += OnPlaybackProgress; + } + + /// <summary> + /// Get transcoding job. + /// </summary> + /// <param name="playSessionId">Playback session id.</param> + /// <returns>The transcoding job.</returns> + public TranscodingJobDto GetTranscodingJob(string playSessionId) + { + lock (_activeTranscodingJobs) + { + return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); + } + } + + /// <summary> + /// Get transcoding job. + /// </summary> + /// <param name="path">Path to the transcoding file.</param> + /// <param name="type">The <see cref="TranscodingJobType"/>.</param> + /// <returns>The transcoding job.</returns> + public TranscodingJobDto GetTranscodingJob(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) + { + return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + } + } + + /// <summary> + /// Ping transcoding job. + /// </summary> + /// <param name="playSessionId">Play session id.</param> + /// <param name="isUserPaused">Is user paused.</param> + /// <exception cref="ArgumentNullException">Play session id is null.</exception> + public void PingTranscodingJob(string playSessionId, bool? isUserPaused) + { + if (string.IsNullOrEmpty(playSessionId)) + { + throw new ArgumentNullException(nameof(playSessionId)); + } + + _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); + + List<TranscodingJobDto> jobs; + + lock (_activeTranscodingJobs) + { + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably + jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + foreach (var job in jobs) + { + if (isUserPaused.HasValue) + { + _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); + job.IsUserPaused = isUserPaused.Value; + } + + PingTimer(job, true); + } + } + + private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn) + { + if (job.HasExited) + { + job.StopKillTimer(); + return; + } + + var timerDuration = 10000; + + if (job.Type != TranscodingJobType.Progressive) + { + timerDuration = 60000; + } + + job.PingTimeout = timerDuration; + job.LastPingDate = DateTime.UtcNow; + + // Don't start the timer for playback checkins with progressive streaming + if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn) + { + job.StartKillTimer(OnTranscodeKillTimerStopped); + } + else + { + job.ChangeKillTimerIfStarted(); + } + } + + /// <summary> + /// Called when [transcode kill timer stopped]. + /// </summary> + /// <param name="state">The state.</param> + private async void OnTranscodeKillTimerStopped(object state) + { + var job = (TranscodingJobDto)state; + + if (!job.HasExited && job.Type != TranscodingJobType.Progressive) + { + var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; + + if (timeSinceLastPing < job.PingTimeout) + { + job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout); + return; + } + } + + _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + + await KillTranscodingJob(job, true, path => true).ConfigureAwait(false); + } + + /// <summary> + /// Kills the single transcoding job. + /// </summary> + /// <param name="deviceId">The device id.</param> + /// <param name="playSessionId">The play session identifier.</param> + /// <param name="deleteFiles">The delete files.</param> + /// <returns>Task.</returns> + public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func<string, bool> deleteFiles) + { + return KillTranscodingJobs( + j => string.IsNullOrWhiteSpace(playSessionId) + ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) + : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), deleteFiles); + } + + /// <summary> + /// Kills the transcoding jobs. + /// </summary> + /// <param name="killJob">The kill job.</param> + /// <param name="deleteFiles">The delete files.</param> + /// <returns>Task.</returns> + private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles) + { + var jobs = new List<TranscodingJobDto>(); + + lock (_activeTranscodingJobs) + { + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably + jobs.AddRange(_activeTranscodingJobs.Where(killJob)); + } + + if (jobs.Count == 0) + { + return Task.CompletedTask; + } + + IEnumerable<Task> GetKillJobs() + { + foreach (var job in jobs) + { + yield return KillTranscodingJob(job, false, deleteFiles); + } + } + + return Task.WhenAll(GetKillJobs()); + } + + /// <summary> + /// Kills the transcoding job. + /// </summary> + /// <param name="job">The job.</param> + /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param> + /// <param name="delete">The delete.</param> + private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete) + { + job.DisposeKillTimer(); + + _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + + lock (_activeTranscodingJobs) + { + _activeTranscodingJobs.Remove(job); + + if (!job.CancellationTokenSource!.IsCancellationRequested) + { + job.CancellationTokenSource.Cancel(); + } + } + + lock (_transcodingLocks) + { + _transcodingLocks.Remove(job.Path!); + } + + lock (job.ProcessLock!) + { + job.TranscodingThrottler?.Stop().GetAwaiter().GetResult(); + + var process = job.Process; + + var hasExited = job.HasExited; + + if (!hasExited) + { + try + { + _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path); + + process!.StandardInput.WriteLine("q"); + + // Need to wait because killing is asynchronous + if (!process.WaitForExit(5000)) + { + _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path); + process.Kill(); + } + } + catch (InvalidOperationException) + { + } + } + } + + if (delete(job.Path!)) + { + await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); + } + + if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); + } + } + } + + private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) + { + if (retryCount >= 10) + { + return; + } + + _logger.LogInformation("Deleting partial stream file(s) {Path}", path); + + await Task.Delay(delayMs).ConfigureAwait(false); + + try + { + if (jobType == TranscodingJobType.Progressive) + { + DeleteProgressivePartialStreamFiles(path); + } + else + { + DeleteHlsPartialStreamFiles(path); + } + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + + await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + } + } + + /// <summary> + /// Deletes the progressive partial stream files. + /// </summary> + /// <param name="outputFilePath">The output file path.</param> + private void DeleteProgressivePartialStreamFiles(string outputFilePath) + { + if (File.Exists(outputFilePath)) + { + _fileSystem.DeleteFile(outputFilePath); + } + } + + /// <summary> + /// Deletes the HLS partial stream files. + /// </summary> + /// <param name="outputFilePath">The output file path.</param> + private void DeleteHlsPartialStreamFiles(string outputFilePath) + { + var directory = Path.GetDirectoryName(outputFilePath); + var name = Path.GetFileNameWithoutExtension(outputFilePath); + + var filesToDelete = _fileSystem.GetFilePaths(directory) + .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1); + + List<Exception>? exs = null; + foreach (var file in filesToDelete) + { + try + { + _logger.LogDebug("Deleting HLS file {0}", file); + _fileSystem.DeleteFile(file); + } + catch (IOException ex) + { + (exs ??= new List<Exception>(4)).Add(ex); + _logger.LogError(ex, "Error deleting HLS file {Path}", file); + } + } + + if (exs != null) + { + throw new AggregateException("Error deleting HLS files", exs); + } + } + + /// <summary> + /// Report the transcoding progress to the session manager. + /// </summary> + /// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param> + /// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param> + /// <param name="transcodingPosition">The current transcoding position.</param> + /// <param name="framerate">The framerate of the transcoding job.</param> + /// <param name="percentComplete">The completion percentage of the transcode.</param> + /// <param name="bytesTranscoded">The number of bytes transcoded.</param> + /// <param name="bitRate">The bitrate of the transcoding job.</param> + public void ReportTranscodingProgress( + TranscodingJobDto job, + StreamState state, + TimeSpan? transcodingPosition, + float? framerate, + double? percentComplete, + long? bytesTranscoded, + int? bitRate) + { + var ticks = transcodingPosition?.Ticks; + + if (job != null) + { + job.Framerate = framerate; + job.CompletionPercentage = percentComplete; + job.TranscodingPositionTicks = ticks; + job.BytesTranscoded = bytesTranscoded; + job.BitRate = bitRate; + } + + var deviceId = state.Request.DeviceId; + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + + _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo + { + Bitrate = bitRate ?? state.TotalOutputBitrate, + AudioCodec = audioCodec, + VideoCodec = videoCodec, + Container = state.OutputContainer, + Framerate = framerate, + CompletionPercentage = percentComplete, + Width = state.OutputWidth, + Height = state.OutputHeight, + AudioChannels = state.OutputAudioChannels, + IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), + IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), + TranscodeReasons = state.TranscodeReasons + }); + } + } + + /// <summary> + /// Starts the FFMPEG. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="outputPath">The output path.</param> + /// <param name="commandLineArguments">The command line arguments for ffmpeg.</param> + /// <param name="request">The <see cref="HttpRequest"/>.</param> + /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <param name="workingDirectory">The working directory.</param> + /// <returns>Task.</returns> + public async Task<TranscodingJobDto> StartFfMpeg( + StreamState state, + string outputPath, + string commandLineArguments, + HttpRequest request, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource, + string? workingDirectory = null) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + + await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); + + if (state.VideoRequest != null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + var auth = _authorizationContext.GetAuthorizationInfo(request); + if (auth.User != null && !auth.User.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) + { + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + + throw new ArgumentException("User does not have access to video transcoding"); + } + } + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + UseShellExecute = false, + + // Must consume both stdout and stderr or deadlocks may occur + // RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + FileName = _mediaEncoder.EncoderPath, + Arguments = commandLineArguments, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory, + ErrorDialog = false + }, + EnableRaisingEvents = true + }; + + var transcodingJob = this.OnTranscodeBeginning( + outputPath, + state.Request.PlaySessionId, + state.MediaSource.LiveStreamId, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), + transcodingJobType, + process, + state.Request.DeviceId, + state, + cancellationTokenSource); + + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + _logger.LogInformation(commandLineLogMessage); + + var logFilePrefix = "ffmpeg-transcode"; + if (state.VideoRequest != null + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) + ? "ffmpeg-remux" + : "ffmpeg-directstream"; + } + + var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt"); + + // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); + + var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); + await logStream.WriteAsync(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length, cancellationTokenSource.Token).ConfigureAwait(false); + + process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); + + try + { + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting ffmpeg"); + + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + + throw; + } + + _logger.LogDebug("Launched ffmpeg process"); + state.TranscodingJob = transcodingJob; + + // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); + + // Wait for the file to exist before proceeeding + var ffmpegTargetFile = state.WaitForPath ?? outputPath; + _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); + while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) + { + await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); + } + + _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile); + + if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) + { + await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); + + if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) + { + await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + if (!transcodingJob.HasExited) + { + StartThrottler(state, transcodingJob); + } + + _logger.LogDebug("StartFfMpeg() finished successfully"); + + return transcodingJob; + } + + private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob) + { + if (EnableThrottling(state)) + { + transcodingJob.TranscodingThrottler = state.TranscodingThrottler = new TranscodingThrottler(transcodingJob, new Logger<TranscodingThrottler>(new LoggerFactory()), _serverConfigurationManager, _fileSystem); + state.TranscodingThrottler.Start(); + } + } + + private bool EnableThrottling(StreamState state) + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + + // enable throttling when NOT using hardware acceleration + if (string.IsNullOrEmpty(encodingOptions.HardwareAccelerationType)) + { + return state.InputProtocol == MediaProtocol.File && + state.RunTimeTicks.HasValue && + state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && + state.IsInputVideo && + state.VideoType == VideoType.VideoFile && + !EncodingHelper.IsCopyCodec(state.OutputVideoCodec); + } + + return false; + } + + /// <summary> + /// Called when [transcode beginning]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="playSessionId">The play session identifier.</param> + /// <param name="liveStreamId">The live stream identifier.</param> + /// <param name="transcodingJobId">The transcoding job identifier.</param> + /// <param name="type">The type.</param> + /// <param name="process">The process.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="state">The state.</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <returns>TranscodingJob.</returns> + public TranscodingJobDto OnTranscodeBeginning( + string path, + string? playSessionId, + string? liveStreamId, + string transcodingJobId, + TranscodingJobType type, + Process process, + string? deviceId, + StreamState state, + CancellationTokenSource cancellationTokenSource) + { + lock (_activeTranscodingJobs) + { + var job = new TranscodingJobDto(_loggerFactory.CreateLogger<TranscodingJobDto>()) + { + Type = type, + Path = path, + Process = process, + ActiveRequestCount = 1, + DeviceId = deviceId, + CancellationTokenSource = cancellationTokenSource, + Id = transcodingJobId, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + MediaSource = state.MediaSource + }; + + _activeTranscodingJobs.Add(job); + + ReportTranscodingProgress(job, state, null, null, null, null, null); + + return job; + } + } + + /// <summary> + /// Called when [transcode end]. + /// </summary> + /// <param name="job">The transcode job.</param> + public void OnTranscodeEndRequest(TranscodingJobDto job) + { + job.ActiveRequestCount--; + _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount); + if (job.ActiveRequestCount <= 0) + { + PingTimer(job, false); + } + } + + /// <summary> + /// <summary> + /// The progressive + /// </summary> + /// Called when [transcode failed to start]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="type">The type.</param> + /// <param name="state">The state.</param> + public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) + { + lock (_activeTranscodingJobs) + { + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + + if (job != null) + { + _activeTranscodingJobs.Remove(job); + } + } + + lock (_transcodingLocks) + { + _transcodingLocks.Remove(path); + } + + if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) + { + _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); + } + } + + /// <summary> + /// Processes the exited. + /// </summary> + /// <param name="process">The process.</param> + /// <param name="job">The job.</param> + /// <param name="state">The state.</param> + private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state) + { + if (job != null) + { + job.HasExited = true; + } + + _logger.LogDebug("Disposing stream resources"); + state.Dispose(); + + if (process.ExitCode == 0) + { + _logger.LogInformation("FFMpeg exited with code 0"); + } + else + { + _logger.LogError("FFMpeg exited with code {0}", process.ExitCode); + } + + process.Dispose(); + } + + private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) + { + if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && _isoManager.CanMount(state.MediaPath)) + { + state.IsoMount = await _isoManager.Mount(state.MediaPath, cancellationTokenSource.Token).ConfigureAwait(false); + } + + if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) + { + var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( + new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + _encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl); + + if (state.VideoRequest != null) + { + _encodingHelper.TryStreamCopy(state); + } + } + + if (state.MediaSource.BufferMs.HasValue) + { + await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + /// <summary> + /// Called when [transcode begin request]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="type">The type.</param> + /// <returns>The <see cref="TranscodingJobDto"/>.</returns> + public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) + { + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + + if (job == null) + { + return null; + } + + OnTranscodeBeginRequest(job); + + return job; + } + } + + private void OnTranscodeBeginRequest(TranscodingJobDto job) + { + job.ActiveRequestCount++; + + if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) + { + job.StopKillTimer(); + } + } + + /// <summary> + /// Gets the transcoding lock. + /// </summary> + /// <param name="outputPath">The output path of the transcoded file.</param> + /// <returns>A <see cref="SemaphoreSlim"/>.</returns> + public SemaphoreSlim GetTranscodingLock(string outputPath) + { + lock (_transcodingLocks) + { + if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim result)) + { + result = new SemaphoreSlim(1, 1); + _transcodingLocks[outputPath] = result; + } + + return result; + } + } + + private void OnPlaybackProgress(object sender, PlaybackProgressEventArgs e) + { + if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) + { + PingTranscodingJob(e.PlaySessionId, e.IsPaused); + } + } + + /// <summary> + /// Deletes the encoded media cache. + /// </summary> + private void DeleteEncodedMediaCache() + { + var path = _serverConfigurationManager.GetTranscodePath(); + if (!Directory.Exists(path)) + { + return; + } + + foreach (var file in _fileSystem.GetFilePaths(path, true)) + { + _fileSystem.DeleteFile(file); + } + } + + /// <summary> + /// Dispose transcoding job helper. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose throttler. + /// </summary> + /// <param name="disposing">Disposing.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _loggerFactory.Dispose(); + _sessionManager!.PlaybackProgress -= OnPlaybackProgress; + _sessionManager!.PlaybackStart -= OnPlaybackProgress; + } + } + } +} diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 3693d5122..24bc07b66 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -16,10 +16,13 @@ <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="3.1.6" /> <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.6" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="5.5.1" /> + <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="5.5.1" /> </ItemGroup> <ItemGroup> + <ProjectReference Include="..\Emby.Dlna\Emby.Dlna.csproj" /> <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> </ItemGroup> diff --git a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs new file mode 100644 index 000000000..3b827ec12 --- /dev/null +++ b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Models.ConfigurationDtos +{ + /// <summary> + /// Media Encoder Path Dto. + /// </summary> + public class MediaEncoderPathDto + { + /// <summary> + /// Gets or sets media encoder path. + /// </summary> + public string Path { get; set; } = null!; + + /// <summary> + /// Gets or sets media encoder path type. + /// </summary> + public string PathType { get; set; } = null!; + } +} diff --git a/MediaBrowser.WebDashboard/Api/ConfigurationPageInfo.cs b/Jellyfin.Api/Models/ConfigurationPageInfo.cs index e49a4be8a..2aa6373aa 100644 --- a/MediaBrowser.WebDashboard/Api/ConfigurationPageInfo.cs +++ b/Jellyfin.Api/Models/ConfigurationPageInfo.cs @@ -1,13 +1,18 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Common.Plugins; +using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Plugins; -namespace MediaBrowser.WebDashboard.Api +namespace Jellyfin.Api.Models { + /// <summary> + /// The configuration page info. + /// </summary> public class ConfigurationPageInfo { + /// <summary> + /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. + /// </summary> + /// <param name="page">Instance of <see cref="IPluginConfigurationPage"/> interface.</param> public ConfigurationPageInfo(IPluginConfigurationPage page) { Name = page.Name; @@ -22,6 +27,11 @@ namespace MediaBrowser.WebDashboard.Api } } + /// <summary> + /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. + /// </summary> + /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param> + /// <param name="page">Instance of <see cref="PluginPageInfo"/> interface.</param> public ConfigurationPageInfo(IPlugin plugin, PluginPageInfo page) { Name = page.Name; @@ -40,13 +50,25 @@ namespace MediaBrowser.WebDashboard.Api /// <value>The name.</value> public string Name { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the configurations page is enabled in the main menu. + /// </summary> public bool EnableInMainMenu { get; set; } - public string MenuSection { get; set; } + /// <summary> + /// Gets or sets the menu section. + /// </summary> + public string? MenuSection { get; set; } - public string MenuIcon { get; set; } + /// <summary> + /// Gets or sets the menu icon. + /// </summary> + public string? MenuIcon { get; set; } - public string DisplayName { get; set; } + /// <summary> + /// Gets or sets the display name. + /// </summary> + public string? DisplayName { get; set; } /// <summary> /// Gets or sets the type of the configuration page. @@ -58,6 +80,6 @@ namespace MediaBrowser.WebDashboard.Api /// Gets or sets the plugin id. /// </summary> /// <value>The plugin id.</value> - public string PluginId { get; set; } + public string? PluginId { get; set; } } } diff --git a/Jellyfin.Api/Models/DisplayPreferencesDtos/DisplayPreferencesDto.cs b/Jellyfin.Api/Models/DisplayPreferencesDtos/DisplayPreferencesDto.cs new file mode 100644 index 000000000..249d828d3 --- /dev/null +++ b/Jellyfin.Api/Models/DisplayPreferencesDtos/DisplayPreferencesDto.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using Jellyfin.Data.Enums; + +namespace Jellyfin.Api.Models.DisplayPreferencesDtos +{ + /// <summary> + /// Defines the display preferences for any item that supports them (usually Folders). + /// </summary> + public class DisplayPreferencesDto + { + /// <summary> + /// Initializes a new instance of the <see cref="DisplayPreferencesDto" /> class. + /// </summary> + public DisplayPreferencesDto() + { + RememberIndexing = false; + PrimaryImageHeight = 250; + PrimaryImageWidth = 250; + ShowBackdrop = true; + CustomPrefs = new Dictionary<string, string>(); + } + + /// <summary> + /// Gets or sets the user id. + /// </summary> + /// <value>The user id.</value> + public string? Id { get; set; } + + /// <summary> + /// Gets or sets the type of the view. + /// </summary> + /// <value>The type of the view.</value> + public string? ViewType { get; set; } + + /// <summary> + /// Gets or sets the sort by. + /// </summary> + /// <value>The sort by.</value> + public string? SortBy { get; set; } + + /// <summary> + /// Gets or sets the index by. + /// </summary> + /// <value>The index by.</value> + public string? IndexBy { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether [remember indexing]. + /// </summary> + /// <value><c>true</c> if [remember indexing]; otherwise, <c>false</c>.</value> + public bool RememberIndexing { get; set; } + + /// <summary> + /// Gets or sets the height of the primary image. + /// </summary> + /// <value>The height of the primary image.</value> + public int PrimaryImageHeight { get; set; } + + /// <summary> + /// Gets or sets the width of the primary image. + /// </summary> + /// <value>The width of the primary image.</value> + public int PrimaryImageWidth { get; set; } + + /// <summary> + /// Gets the custom prefs. + /// </summary> + /// <value>The custom prefs.</value> + public Dictionary<string, string> CustomPrefs { get; } + + /// <summary> + /// Gets or sets the scroll direction. + /// </summary> + /// <value>The scroll direction.</value> + public ScrollDirection ScrollDirection { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to show backdrops on this item. + /// </summary> + /// <value><c>true</c> if showing backdrops; otherwise, <c>false</c>.</value> + public bool ShowBackdrop { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether [remember sorting]. + /// </summary> + /// <value><c>true</c> if [remember sorting]; otherwise, <c>false</c>.</value> + public bool RememberSorting { get; set; } + + /// <summary> + /// Gets or sets the sort order. + /// </summary> + /// <value>The sort order.</value> + public SortOrder SortOrder { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether [show sidebar]. + /// </summary> + /// <value><c>true</c> if [show sidebar]; otherwise, <c>false</c>.</value> + public bool ShowSidebar { get; set; } + + /// <summary> + /// Gets or sets the client. + /// </summary> + public string? Client { get; set; } + } +} diff --git a/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs b/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs new file mode 100644 index 000000000..92be15b8a --- /dev/null +++ b/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs @@ -0,0 +1,13 @@ +namespace Jellyfin.Api.Models.EnvironmentDtos +{ + /// <summary> + /// Default directory browser info. + /// </summary> + public class DefaultDirectoryBrowserInfoDto + { + /// <summary> + /// Gets or sets the path. + /// </summary> + public string? Path { get; set; } + } +} diff --git a/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs b/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs new file mode 100644 index 000000000..418c11c2d --- /dev/null +++ b/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Api.Models.EnvironmentDtos +{ + /// <summary> + /// Validate path object. + /// </summary> + public class ValidatePathDto + { + /// <summary> + /// Gets or sets a value indicating whether validate if path is writable. + /// </summary> + public bool ValidateWritable { get; set; } + + /// <summary> + /// Gets or sets the path. + /// </summary> + public string? Path { get; set; } + + /// <summary> + /// Gets or sets is path file. + /// </summary> + public bool? IsFile { get; set; } + } +} diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs new file mode 100644 index 000000000..358434434 --- /dev/null +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Models.LibraryDtos +{ + /// <summary> + /// Library option info dto. + /// </summary> + public class LibraryOptionInfoDto + { + /// <summary> + /// Gets or sets name. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether default enabled. + /// </summary> + public bool DefaultEnabled { get; set; } + } +} diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs new file mode 100644 index 000000000..33eda33cb --- /dev/null +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Jellyfin.Api.Models.LibraryDtos +{ + /// <summary> + /// Library options result dto. + /// </summary> + public class LibraryOptionsResultDto + { + /// <summary> + /// Gets or sets the metadata savers. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataSavers", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] MetadataSavers { get; set; } = null!; + + /// <summary> + /// Gets or sets the metadata readers. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataReaders", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] MetadataReaders { get; set; } = null!; + + /// <summary> + /// Gets or sets the subtitle fetchers. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SubtitleFetchers", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] SubtitleFetchers { get; set; } = null!; + + /// <summary> + /// Gets or sets the type options. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "TypeOptions", Justification = "Imported from ServiceStack")] + public LibraryTypeOptionsDto[] TypeOptions { get; set; } = null!; + } +} diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs new file mode 100644 index 000000000..ad031e95e --- /dev/null +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.Api.Models.LibraryDtos +{ + /// <summary> + /// Library type options dto. + /// </summary> + public class LibraryTypeOptionsDto + { + /// <summary> + /// Gets or sets the type. + /// </summary> + public string? Type { get; set; } + + /// <summary> + /// Gets or sets the metadata fetchers. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataFetchers", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] MetadataFetchers { get; set; } = null!; + + /// <summary> + /// Gets or sets the image fetchers. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "ImageFetchers", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] ImageFetchers { get; set; } = null!; + + /// <summary> + /// Gets or sets the supported image types. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SupportedImageTypes", Justification = "Imported from ServiceStack")] + public ImageType[] SupportedImageTypes { get; set; } = null!; + + /// <summary> + /// Gets or sets the default image options. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "DefaultImageOptions", Justification = "Imported from ServiceStack")] + public ImageOption[] DefaultImageOptions { get; set; } = null!; + } +} diff --git a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs new file mode 100644 index 000000000..991dbfc50 --- /dev/null +++ b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs @@ -0,0 +1,19 @@ +namespace Jellyfin.Api.Models.LibraryDtos +{ + /// <summary> + /// Media Update Info Dto. + /// </summary> + public class MediaUpdateInfoDto + { + /// <summary> + /// Gets or sets media path. + /// </summary> + public string? Path { get; set; } + + /// <summary> + /// Gets or sets media update type. + /// Created, Modified, Deleted. + /// </summary> + public string? UpdateType { get; set; } + } +} diff --git a/Jellyfin.Api/Models/LibraryStructureDto/LibraryOptionsDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/LibraryOptionsDto.cs new file mode 100644 index 000000000..a13cb90db --- /dev/null +++ b/Jellyfin.Api/Models/LibraryStructureDto/LibraryOptionsDto.cs @@ -0,0 +1,15 @@ +using MediaBrowser.Model.Configuration; + +namespace Jellyfin.Api.Models.LibraryStructureDto +{ + /// <summary> + /// Library options dto. + /// </summary> + public class LibraryOptionsDto + { + /// <summary> + /// Gets or sets library options. + /// </summary> + public LibraryOptions? LibraryOptions { get; set; } + } +}
\ No newline at end of file diff --git a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs new file mode 100644 index 000000000..f65988259 --- /dev/null +++ b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using MediaBrowser.Model.Configuration; + +namespace Jellyfin.Api.Models.LibraryStructureDto +{ + /// <summary> + /// Media Path dto. + /// </summary> + public class MediaPathDto + { + /// <summary> + /// Gets or sets the name of the library. + /// </summary> + [Required] + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the path to add. + /// </summary> + public string? Path { get; set; } + + /// <summary> + /// Gets or sets the path info. + /// </summary> + public MediaPathInfo? PathInfo { get; set; } + } +}
\ No newline at end of file diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs new file mode 100644 index 000000000..970d8acdb --- /dev/null +++ b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.Api.Models.LiveTvDtos +{ + /// <summary> + /// Channel mapping options dto. + /// </summary> + public class ChannelMappingOptionsDto + { + /// <summary> + /// Gets or sets list of tuner channels. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA2227:ReadOnlyRemoveSetter", MessageId = "TunerChannels", Justification = "Imported from ServiceStack")] + public List<TunerChannelMapping> TunerChannels { get; set; } = null!; + + /// <summary> + /// Gets or sets list of provider channels. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA2227:ReadOnlyRemoveSetter", MessageId = "ProviderChannels", Justification = "Imported from ServiceStack")] + public List<NameIdPair> ProviderChannels { get; set; } = null!; + + /// <summary> + /// Gets or sets list of mappings. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:DontReturnArrays", MessageId = "Mappings", Justification = "Imported from ServiceStack")] + public NameValuePair[] Mappings { get; set; } = null!; + + /// <summary> + /// Gets or sets provider name. + /// </summary> + public string? ProviderName { get; set; } + } +} diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs new file mode 100644 index 000000000..d7eaab30d --- /dev/null +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -0,0 +1,166 @@ +using System; + +namespace Jellyfin.Api.Models.LiveTvDtos +{ + /// <summary> + /// Get programs dto. + /// </summary> + public class GetProgramsDto + { + /// <summary> + /// Gets or sets the channels to return guide information for. + /// </summary> + public string? ChannelIds { get; set; } + + /// <summary> + /// Gets or sets optional. Filter by user id. + /// </summary> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets the minimum premiere start date. + /// Optional. + /// </summary> + public DateTime? MinStartDate { get; set; } + + /// <summary> + /// Gets or sets filter by programs that have completed airing, or not. + /// Optional. + /// </summary> + public bool? HasAired { get; set; } + + /// <summary> + /// Gets or sets filter by programs that are currently airing, or not. + /// Optional. + /// </summary> + public bool? IsAiring { get; set; } + + /// <summary> + /// Gets or sets the maximum premiere start date. + /// Optional. + /// </summary> + public DateTime? MaxStartDate { get; set; } + + /// <summary> + /// Gets or sets the minimum premiere end date. + /// Optional. + /// </summary> + public DateTime? MinEndDate { get; set; } + + /// <summary> + /// Gets or sets the maximum premiere end date. + /// Optional. + /// </summary> + public DateTime? MaxEndDate { get; set; } + + /// <summary> + /// Gets or sets filter for movies. + /// Optional. + /// </summary> + public bool? IsMovie { get; set; } + + /// <summary> + /// Gets or sets filter for series. + /// Optional. + /// </summary> + public bool? IsSeries { get; set; } + + /// <summary> + /// Gets or sets filter for news. + /// Optional. + /// </summary> + public bool? IsNews { get; set; } + + /// <summary> + /// Gets or sets filter for kids. + /// Optional. + /// </summary> + public bool? IsKids { get; set; } + + /// <summary> + /// Gets or sets filter for sports. + /// Optional. + /// </summary> + public bool? IsSports { get; set; } + + /// <summary> + /// Gets or sets the record index to start at. All items with a lower index will be dropped from the results. + /// Optional. + /// </summary> + public int? StartIndex { get; set; } + + /// <summary> + /// Gets or sets the maximum number of records to return. + /// Optional. + /// </summary> + public int? Limit { get; set; } + + /// <summary> + /// Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate. + /// Optional. + /// </summary> + public string? SortBy { get; set; } + + /// <summary> + /// Gets or sets sort Order - Ascending,Descending. + /// </summary> + public string? SortOrder { get; set; } + + /// <summary> + /// Gets or sets the genres to return guide information for. + /// </summary> + public string? Genres { get; set; } + + /// <summary> + /// Gets or sets the genre ids to return guide information for. + /// </summary> + public string? GenreIds { get; set; } + + /// <summary> + /// Gets or sets include image information in output. + /// Optional. + /// </summary> + public bool? EnableImages { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether retrieve total record count. + /// </summary> + public bool EnableTotalRecordCount { get; set; } = true; + + /// <summary> + /// Gets or sets the max number of images to return, per image type. + /// Optional. + /// </summary> + public int? ImageTypeLimit { get; set; } + + /// <summary> + /// Gets or sets the image types to include in the output. + /// Optional. + /// </summary> + public string? EnableImageTypes { get; set; } + + /// <summary> + /// Gets or sets include user data. + /// Optional. + /// </summary> + public bool? EnableUserData { get; set; } + + /// <summary> + /// Gets or sets filter by series timer id. + /// Optional. + /// </summary> + public string? SeriesTimerId { get; set; } + + /// <summary> + /// Gets or sets filter by library series id. + /// Optional. + /// </summary> + public Guid LibrarySeriesId { get; set; } + + /// <summary> + /// 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. + /// </summary> + public string? Fields { get; set; } + } +} diff --git a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs new file mode 100644 index 000000000..f797a3807 --- /dev/null +++ b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.MediaInfo; + +namespace Jellyfin.Api.Models.MediaInfoDtos +{ + /// <summary> + /// Open live stream dto. + /// </summary> + public class OpenLiveStreamDto + { + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } + + /// <summary> + /// Gets or sets the device play protocols. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:DontReturnArrays", MessageId = "DevicePlayProtocols", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "SA1011:ClosingBracketsSpace", MessageId = "DevicePlayProtocols", Justification = "Imported from ServiceStack")] + public MediaProtocol[]? DirectPlayProtocols { get; set; } + } +} diff --git a/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs b/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs new file mode 100644 index 000000000..af5239ec2 --- /dev/null +++ b/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs @@ -0,0 +1,51 @@ +using System; +using MediaBrowser.Model.Notifications; + +namespace Jellyfin.Api.Models.NotificationDtos +{ + /// <summary> + /// The notification DTO. + /// </summary> + public class NotificationDto + { + /// <summary> + /// Gets or sets the notification ID. Defaults to an empty string. + /// </summary> + public string Id { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the notification's user ID. Defaults to an empty string. + /// </summary> + public string UserId { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the notification date. + /// </summary> + public DateTime Date { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the notification has been read. Defaults to false. + /// </summary> + public bool IsRead { get; set; } = false; + + /// <summary> + /// Gets or sets the notification's name. Defaults to an empty string. + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the notification's description. Defaults to an empty string. + /// </summary> + public string Description { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the notification's URL. Defaults to an empty string. + /// </summary> + public string Url { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the notification level. + /// </summary> + public NotificationLevel Level { get; set; } + } +} diff --git a/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs b/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs new file mode 100644 index 000000000..64e92bd83 --- /dev/null +++ b/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Api.Models.NotificationDtos +{ + /// <summary> + /// A list of notifications with the total record count for pagination. + /// </summary> + public class NotificationResultDto + { + /// <summary> + /// Gets or sets the current page of notifications. + /// </summary> + public IReadOnlyList<NotificationDto> Notifications { get; set; } = Array.Empty<NotificationDto>(); + + /// <summary> + /// Gets or sets the total number of notifications. + /// </summary> + public int TotalRecordCount { get; set; } + } +} diff --git a/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs b/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs new file mode 100644 index 000000000..0568dea66 --- /dev/null +++ b/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs @@ -0,0 +1,20 @@ +using MediaBrowser.Model.Notifications; + +namespace Jellyfin.Api.Models.NotificationDtos +{ + /// <summary> + /// The notification summary DTO. + /// </summary> + public class NotificationsSummaryDto + { + /// <summary> + /// Gets or sets the number of unread notifications. + /// </summary> + public int UnreadCount { get; set; } + + /// <summary> + /// Gets or sets the maximum unread notification level. + /// </summary> + public NotificationLevel? MaxUnreadNotificationLevel { get; set; } + } +} diff --git a/MediaBrowser.Api/TranscodingJob.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs index bfc311a27..b9507a4e5 100644 --- a/MediaBrowser.Api/TranscodingJob.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs @@ -1,101 +1,174 @@ -using System; +using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Threading; -using MediaBrowser.Api.Playback; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using Microsoft.Extensions.Logging; -namespace MediaBrowser.Api +namespace Jellyfin.Api.Models.PlaybackDtos { /// <summary> /// Class TranscodingJob. /// </summary> - public class TranscodingJob + public class TranscodingJobDto { /// <summary> + /// The process lock. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] + public readonly object ProcessLock = new object(); + + /// <summary> + /// Timer lock. + /// </summary> + private readonly object _timerLock = new object(); + + /// <summary> + /// Initializes a new instance of the <see cref="TranscodingJobDto"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobDto}"/> interface.</param> + public TranscodingJobDto(ILogger<TranscodingJobDto> logger) + { + Logger = logger; + } + + /// <summary> /// Gets or sets the play session identifier. /// </summary> /// <value>The play session identifier.</value> - public string PlaySessionId { get; set; } + public string? PlaySessionId { get; set; } /// <summary> /// Gets or sets the live stream identifier. /// </summary> /// <value>The live stream identifier.</value> - public string LiveStreamId { get; set; } + public string? LiveStreamId { get; set; } + /// <summary> + /// Gets or sets a value indicating whether is live output. + /// </summary> public bool IsLiveOutput { get; set; } /// <summary> /// Gets or sets the path. /// </summary> /// <value>The path.</value> - public MediaSourceInfo MediaSource { get; set; } + public MediaSourceInfo? MediaSource { get; set; } + + /// <summary> + /// Gets or sets path. + /// </summary> + public string? Path { get; set; } - public string Path { get; set; } /// <summary> /// Gets or sets the type. /// </summary> /// <value>The type.</value> public TranscodingJobType Type { get; set; } + /// <summary> /// Gets or sets the process. /// </summary> /// <value>The process.</value> - public Process Process { get; set; } + public Process? Process { get; set; } + + /// <summary> + /// Gets logger. + /// </summary> + public ILogger<TranscodingJobDto> Logger { get; private set; } - public ILogger Logger { get; private set; } /// <summary> /// Gets or sets the active request count. /// </summary> /// <value>The active request count.</value> public int ActiveRequestCount { get; set; } + /// <summary> /// Gets or sets the kill timer. /// </summary> /// <value>The kill timer.</value> - private Timer KillTimer { get; set; } + private Timer? KillTimer { get; set; } - public string DeviceId { get; set; } - - public CancellationTokenSource CancellationTokenSource { get; set; } + /// <summary> + /// Gets or sets device id. + /// </summary> + public string? DeviceId { get; set; } - public object ProcessLock = new object(); + /// <summary> + /// Gets or sets cancellation token source. + /// </summary> + public CancellationTokenSource? CancellationTokenSource { get; set; } + /// <summary> + /// Gets or sets a value indicating whether has exited. + /// </summary> public bool HasExited { get; set; } + /// <summary> + /// Gets or sets a value indicating whether is user paused. + /// </summary> public bool IsUserPaused { get; set; } - public string Id { get; set; } + /// <summary> + /// Gets or sets id. + /// </summary> + public string? Id { get; set; } + /// <summary> + /// Gets or sets framerate. + /// </summary> public float? Framerate { get; set; } + /// <summary> + /// Gets or sets completion percentage. + /// </summary> public double? CompletionPercentage { get; set; } + /// <summary> + /// Gets or sets bytes downloaded. + /// </summary> public long? BytesDownloaded { get; set; } + /// <summary> + /// Gets or sets bytes transcoded. + /// </summary> public long? BytesTranscoded { get; set; } + /// <summary> + /// Gets or sets bit rate. + /// </summary> public int? BitRate { get; set; } + /// <summary> + /// Gets or sets transcoding position ticks. + /// </summary> public long? TranscodingPositionTicks { get; set; } + /// <summary> + /// Gets or sets download position ticks. + /// </summary> public long? DownloadPositionTicks { get; set; } - public TranscodingThrottler TranscodingThrottler { get; set; } - - private readonly object _timerLock = new object(); + /// <summary> + /// Gets or sets transcoding throttler. + /// </summary> + public TranscodingThrottler? TranscodingThrottler { get; set; } + /// <summary> + /// Gets or sets last ping date. + /// </summary> public DateTime LastPingDate { get; set; } + /// <summary> + /// Gets or sets ping timeout. + /// </summary> public int PingTimeout { get; set; } - public TranscodingJob(ILogger logger) - { - Logger = logger; - } - + /// <summary> + /// Stop kill timer. + /// </summary> public void StopKillTimer() { lock (_timerLock) @@ -104,6 +177,9 @@ namespace MediaBrowser.Api } } + /// <summary> + /// Dispose kill timer. + /// </summary> public void DisposeKillTimer() { lock (_timerLock) @@ -116,11 +192,20 @@ namespace MediaBrowser.Api } } + /// <summary> + /// Start kill timer. + /// </summary> + /// <param name="callback">Callback action.</param> public void StartKillTimer(Action<object> callback) { StartKillTimer(callback, PingTimeout); } + /// <summary> + /// Start kill timer. + /// </summary> + /// <param name="callback">Callback action.</param> + /// <param name="intervalMs">Callback interval.</param> public void StartKillTimer(Action<object> callback, int intervalMs) { if (HasExited) @@ -143,6 +228,9 @@ namespace MediaBrowser.Api } } + /// <summary> + /// Change kill timer if started. + /// </summary> public void ChangeKillTimerIfStarted() { if (HasExited) diff --git a/MediaBrowser.Api/Playback/TranscodingThrottler.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs index 0e73d77ef..b5e42ea29 100644 --- a/MediaBrowser.Api/Playback/TranscodingThrottler.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; @@ -6,18 +6,28 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; -namespace MediaBrowser.Api.Playback +namespace Jellyfin.Api.Models.PlaybackDtos { + /// <summary> + /// Transcoding throttler. + /// </summary> public class TranscodingThrottler : IDisposable { - private readonly TranscodingJob _job; - private readonly ILogger _logger; - private Timer _timer; - private bool _isPaused; + private readonly TranscodingJobDto _job; + private readonly ILogger<TranscodingThrottler> _logger; private readonly IConfigurationManager _config; private readonly IFileSystem _fileSystem; + private Timer? _timer; + private bool _isPaused; - public TranscodingThrottler(TranscodingJob job, ILogger logger, IConfigurationManager config, IFileSystem fileSystem) + /// <summary> + /// Initializes a new instance of the <see cref="TranscodingThrottler"/> class. + /// </summary> + /// <param name="job">Transcoding job dto.</param> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingThrottler}"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + public TranscodingThrottler(TranscodingJobDto job, ILogger<TranscodingThrottler> logger, IConfigurationManager config, IFileSystem fileSystem) { _job = job; _logger = logger; @@ -25,14 +35,70 @@ namespace MediaBrowser.Api.Playback _fileSystem = fileSystem; } - private EncodingOptions GetOptions() + /// <summary> + /// Start timer. + /// </summary> + public void Start() { - return _config.GetConfiguration<EncodingOptions>("encoding"); + _timer = new Timer(TimerCallback, null, 5000, 5000); } - public void Start() + /// <summary> + /// Unpause transcoding. + /// </summary> + /// <returns>A <see cref="Task"/>.</returns> + public async Task UnpauseTranscoding() { - _timer = new Timer(TimerCallback, null, 5000, 5000); + if (_isPaused) + { + _logger.LogDebug("Sending resume command to ffmpeg"); + + try + { + await _job.Process!.StandardInput.WriteLineAsync().ConfigureAwait(false); + _isPaused = false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resuming transcoding"); + } + } + } + + /// <summary> + /// Stop throttler. + /// </summary> + /// <returns>A <see cref="Task"/>.</returns> + public async Task Stop() + { + DisposeTimer(); + await UnpauseTranscoding().ConfigureAwait(false); + } + + /// <summary> + /// Dispose throttler. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose throttler. + /// </summary> + /// <param name="disposing">Disposing.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + DisposeTimer(); + } + } + + private EncodingOptions GetOptions() + { + return _config.GetConfiguration<EncodingOptions>("encoding"); } private async void TimerCallback(object state) @@ -47,11 +113,11 @@ namespace MediaBrowser.Api.Playback if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds)) { - await PauseTranscoding(); + await PauseTranscoding().ConfigureAwait(false); } else { - await UnpauseTranscoding(); + await UnpauseTranscoding().ConfigureAwait(false); } } @@ -63,7 +129,7 @@ namespace MediaBrowser.Api.Playback try { - await _job.Process.StandardInput.WriteAsync("c"); + await _job.Process!.StandardInput.WriteAsync("c").ConfigureAwait(false); _isPaused = true; } catch (Exception ex) @@ -73,25 +139,7 @@ namespace MediaBrowser.Api.Playback } } - public async Task UnpauseTranscoding() - { - if (_isPaused) - { - _logger.LogDebug("Sending resume command to ffmpeg"); - - try - { - await _job.Process.StandardInput.WriteLineAsync(); - _isPaused = false; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error resuming transcoding"); - } - } - } - - private bool IsThrottleAllowed(TranscodingJob job, int thresholdSeconds) + private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds) { var bytesDownloaded = job.BytesDownloaded ?? 0; var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; @@ -152,17 +200,6 @@ namespace MediaBrowser.Api.Playback return false; } - public async Task Stop() - { - DisposeTimer(); - await UnpauseTranscoding(); - } - - public void Dispose() - { - DisposeTimer(); - } - private void DisposeTimer() { if (_timer != null) diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs new file mode 100644 index 000000000..0d67c86f7 --- /dev/null +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -0,0 +1,30 @@ +using System; + +namespace Jellyfin.Api.Models.PlaylistDtos +{ + /// <summary> + /// Create new playlist dto. + /// </summary> + public class CreatePlaylistDto + { + /// <summary> + /// Gets or sets the name of the new playlist. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets item ids to add to the playlist. + /// </summary> + public string? Ids { get; set; } + + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets the media type. + /// </summary> + public string? MediaType { get; set; } + } +} diff --git a/Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs b/Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs new file mode 100644 index 000000000..7f1255f4b --- /dev/null +++ b/Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs @@ -0,0 +1,40 @@ +using System; + +namespace Jellyfin.Api.Models.PluginDtos +{ + /// <summary> + /// MB Registration Record. + /// </summary> + public class MBRegistrationRecord + { + /// <summary> + /// Gets or sets expiration date. + /// </summary> + public DateTime ExpirationDate { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is registered. + /// </summary> + public bool IsRegistered { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether reg checked. + /// </summary> + public bool RegChecked { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether reg error. + /// </summary> + public bool RegError { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether trial version. + /// </summary> + public bool TrialVersion { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is valid. + /// </summary> + public bool IsValid { get; set; } + } +} diff --git a/Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs b/Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs new file mode 100644 index 000000000..a90398425 --- /dev/null +++ b/Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Models.PluginDtos +{ + /// <summary> + /// Plugin security info. + /// </summary> + public class PluginSecurityInfo + { + /// <summary> + /// Gets or sets the supporter key. + /// </summary> + public string? SupporterKey { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is mb supporter. + /// </summary> + public bool IsMbSupporter { get; set; } + } +} diff --git a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs index 5a83a030d..a5f012245 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace Jellyfin.Api.Models.StartupDtos { /// <summary> @@ -10,16 +8,16 @@ namespace Jellyfin.Api.Models.StartupDtos /// <summary> /// Gets or sets UI language culture. /// </summary> - public string UICulture { get; set; } + public string? UICulture { get; set; } /// <summary> /// Gets or sets the metadata country code. /// </summary> - public string MetadataCountryCode { get; set; } + public string? MetadataCountryCode { get; set; } /// <summary> /// Gets or sets the preferred language for the metadata. /// </summary> - public string PreferredMetadataLanguage { get; set; } + public string? PreferredMetadataLanguage { get; set; } } } diff --git a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs index 0dbb245ec..e4c973548 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace Jellyfin.Api.Models.StartupDtos { /// <summary> @@ -10,11 +8,11 @@ namespace Jellyfin.Api.Models.StartupDtos /// <summary> /// Gets or sets the username. /// </summary> - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the user's password. /// </summary> - public string Password { get; set; } + public string? Password { get; set; } } } diff --git a/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs new file mode 100644 index 000000000..3791fadbe --- /dev/null +++ b/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs @@ -0,0 +1,13 @@ +namespace Jellyfin.Api.Models.StreamingDtos +{ + /// <summary> + /// The hls video request dto. + /// </summary> + public class HlsAudioRequestDto : StreamingRequestDto + { + /// <summary> + /// Gets or sets a value indicating whether enable adaptive bitrate streaming. + /// </summary> + public bool EnableAdaptiveBitrateStreaming { get; set; } + } +} diff --git a/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs new file mode 100644 index 000000000..7a4be091b --- /dev/null +++ b/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs @@ -0,0 +1,13 @@ +namespace Jellyfin.Api.Models.StreamingDtos +{ + /// <summary> + /// The hls video request dto. + /// </summary> + public class HlsVideoRequestDto : VideoRequestDto + { + /// <summary> + /// Gets or sets a value indicating whether enable adaptive bitrate streaming. + /// </summary> + public bool EnableAdaptiveBitrateStreaming { get; set; } + } +} diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs new file mode 100644 index 000000000..e95f2d1f4 --- /dev/null +++ b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs @@ -0,0 +1,201 @@ +using System; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; + +namespace Jellyfin.Api.Models.StreamingDtos +{ + /// <summary> + /// The stream state dto. + /// </summary> + public class StreamState : EncodingJobInfo, IDisposable + { + private readonly IMediaSourceManager _mediaSourceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="StreamState" /> class. + /// </summary> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager" /> interface.</param> + /// <param name="transcodingType">The <see cref="TranscodingJobType" />.</param> + /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper" /> singleton.</param> + public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, TranscodingJobHelper transcodingJobHelper) + : base(transcodingType) + { + _mediaSourceManager = mediaSourceManager; + _transcodingJobHelper = transcodingJobHelper; + } + + /// <summary> + /// Gets or sets the requested url. + /// </summary> + public string? RequestedUrl { get; set; } + + /// <summary> + /// Gets or sets the request. + /// </summary> + public StreamingRequestDto Request + { + get => (StreamingRequestDto)BaseRequest; + set + { + BaseRequest = value; + IsVideoRequest = VideoRequest != null; + } + } + + /// <summary> + /// Gets or sets the transcoding throttler. + /// </summary> + public TranscodingThrottler? TranscodingThrottler { get; set; } + + /// <summary> + /// Gets the video request. + /// </summary> + public VideoRequestDto? VideoRequest => Request! as VideoRequestDto; + + /// <summary> + /// Gets or sets the direct stream provicer. + /// </summary> + public IDirectStreamProvider? DirectStreamProvider { get; set; } + + /// <summary> + /// Gets or sets the path to wait for. + /// </summary> + public string? WaitForPath { get; set; } + + /// <summary> + /// Gets a value indicating whether the request outputs video. + /// </summary> + public bool IsOutputVideo => Request is VideoRequestDto; + + /// <summary> + /// Gets the segment length. + /// </summary> + public int SegmentLength + { + get + { + if (Request.SegmentLength.HasValue) + { + return Request.SegmentLength.Value; + } + + if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) + { + var userAgent = UserAgent ?? string.Empty; + + if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) + { + return 6; + } + + if (IsSegmentedLiveStream) + { + return 3; + } + + return 6; + } + + return 3; + } + } + + /// <summary> + /// Gets the minimum number of segments. + /// </summary> + public int MinSegments + { + get + { + if (Request.MinSegments.HasValue) + { + return Request.MinSegments.Value; + } + + return SegmentLength >= 10 ? 2 : 3; + } + } + + /// <summary> + /// Gets or sets the user agent. + /// </summary> + public string? UserAgent { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to estimate the content length. + /// </summary> + public bool EstimateContentLength { get; set; } + + /// <summary> + /// Gets or sets the transcode seek info. + /// </summary> + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable dlna headers. + /// </summary> + public bool EnableDlnaHeaders { get; set; } + + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } + + /// <summary> + /// Gets or sets the transcoding job. + /// </summary> + public TranscodingJobDto? TranscodingJob { get; set; } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <inheritdoc /> + public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + { + _transcodingJobHelper.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); + } + + /// <summary> + /// Disposes the stream state. + /// </summary> + /// <param name="disposing">Whether the object is currently beeing disposed.</param> + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + // REVIEW: Is this the right place for this? + if (MediaSource.RequiresClosing + && string.IsNullOrWhiteSpace(Request.LiveStreamId) + && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) + { + _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); + } + + TranscodingThrottler?.Dispose(); + } + + TranscodingThrottler = null; + TranscodingJob = null; + + _disposed = true; + } + } +} diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs new file mode 100644 index 000000000..1791b0370 --- /dev/null +++ b/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs @@ -0,0 +1,45 @@ +using MediaBrowser.Controller.MediaEncoding; + +namespace Jellyfin.Api.Models.StreamingDtos +{ + /// <summary> + /// The audio streaming request dto. + /// </summary> + public class StreamingRequestDto : BaseEncodingJobOptions + { + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public string? DeviceProfileId { get; set; } + + /// <summary> + /// Gets or sets the params. + /// </summary> + public string? Params { get; set; } + + /// <summary> + /// Gets or sets the play session id. + /// </summary> + public string? PlaySessionId { get; set; } + + /// <summary> + /// Gets or sets the tag. + /// </summary> + public string? Tag { get; set; } + + /// <summary> + /// Gets or sets the segment container. + /// </summary> + public string? SegmentContainer { get; set; } + + /// <summary> + /// Gets or sets the segment length. + /// </summary> + public int? SegmentLength { get; set; } + + /// <summary> + /// Gets or sets the min segments. + /// </summary> + public int? MinSegments { get; set; } + } +} diff --git a/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs new file mode 100644 index 000000000..cce2a89d4 --- /dev/null +++ b/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs @@ -0,0 +1,19 @@ +namespace Jellyfin.Api.Models.StreamingDtos +{ + /// <summary> + /// The video request dto. + /// </summary> + public class VideoRequestDto : StreamingRequestDto + { + /// <summary> + /// Gets a value indicating whether this instance has fixed resolution. + /// </summary> + /// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value> + public bool HasFixedResolution => Width.HasValue || Height.HasValue; + + /// <summary> + /// Gets or sets a value indicating whether to enable subtitles in the manifest. + /// </summary> + public bool EnableSubtitlesInManifest { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs new file mode 100644 index 000000000..393627435 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The authenticate user by name request body. + /// </summary> + public class AuthenticateUserByName + { + /// <summary> + /// Gets or sets the username. + /// </summary> + public string? Username { get; set; } + + /// <summary> + /// Gets or sets the plain text password. + /// </summary> + public string? Pw { get; set; } + + /// <summary> + /// Gets or sets the sha1-hashed password. + /// </summary> + public string? Password { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs new file mode 100644 index 000000000..1c88d3628 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The create user by name request body. + /// </summary> + public class CreateUserByName + { + /// <summary> + /// Gets or sets the username. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the password. + /// </summary> + public string? Password { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs new file mode 100644 index 000000000..0a173ea1a --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The update user easy password request body. + /// </summary> + public class UpdateUserEasyPassword + { + /// <summary> + /// Gets or sets the new sha1-hashed password. + /// </summary> + public string? NewPassword { get; set; } + + /// <summary> + /// Gets or sets the new password. + /// </summary> + public string? NewPw { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs new file mode 100644 index 000000000..8288dbbc4 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs @@ -0,0 +1,28 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The update user password request body. + /// </summary> + public class UpdateUserPassword + { + /// <summary> + /// Gets or sets the current sha1-hashed password. + /// </summary> + public string? CurrentPassword { get; set; } + + /// <summary> + /// Gets or sets the current plain text password. + /// </summary> + public string? CurrentPw { get; set; } + + /// <summary> + /// Gets or sets the new plain text password. + /// </summary> + public string? NewPw { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs b/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs new file mode 100644 index 000000000..84b6b0958 --- /dev/null +++ b/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Models.UserViewDtos +{ + /// <summary> + /// Special view option dto. + /// </summary> + public class SpecialViewOptionDto + { + /// <summary> + /// Gets or sets view option name. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets view option id. + /// </summary> + public string? Id { get; set; } + } +} diff --git a/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs b/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs new file mode 100644 index 000000000..db55dc34b --- /dev/null +++ b/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs @@ -0,0 +1,15 @@ +using MediaBrowser.Model.Dlna; + +namespace Jellyfin.Api.Models.VideoDtos +{ + /// <summary> + /// Device profile dto. + /// </summary> + public class DeviceProfileDto + { + /// <summary> + /// Gets or sets device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } + } +} diff --git a/MediaBrowser.Api/System/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs index 39976371a..6395b8d62 100644 --- a/MediaBrowser.Api/System/ActivityLogWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs @@ -5,7 +5,7 @@ using MediaBrowser.Model.Activity; using MediaBrowser.Model.Events; using Microsoft.Extensions.Logging; -namespace MediaBrowser.Api.System +namespace Jellyfin.Api.WebSocketListeners { /// <summary> /// Class SessionInfoWebSocketListener. @@ -13,26 +13,27 @@ namespace MediaBrowser.Api.System public class ActivityLogWebSocketListener : BasePeriodicWebSocketListener<ActivityLogEntry[], WebSocketListenerState> { /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - protected override string Name => "ActivityLogEntry"; - - /// <summary> /// The _kernel. /// </summary> private readonly IActivityManager _activityManager; - public ActivityLogWebSocketListener(ILogger<ActivityLogWebSocketListener> logger, IActivityManager activityManager) : base(logger) + /// <summary> + /// Initializes a new instance of the <see cref="ActivityLogWebSocketListener"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{ActivityLogWebSocketListener}"/> interface.</param> + /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> + public ActivityLogWebSocketListener(ILogger<ActivityLogWebSocketListener> logger, IActivityManager activityManager) + : base(logger) { _activityManager = activityManager; _activityManager.EntryCreated += OnEntryCreated; } - private void OnEntryCreated(object sender, GenericEventArgs<ActivityLogEntry> e) - { - SendData(true); - } + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + protected override string Name => "ActivityLogEntry"; /// <summary> /// Gets the data to send. @@ -50,5 +51,10 @@ namespace MediaBrowser.Api.System base.Dispose(dispose); } + + private void OnEntryCreated(object sender, GenericEventArgs<ActivityLogEntry> e) + { + SendData(true); + } } } diff --git a/MediaBrowser.Api/ScheduledTasks/ScheduledTasksWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs index 25dd39f2d..12f815ff7 100644 --- a/MediaBrowser.Api/ScheduledTasks/ScheduledTasksWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs @@ -6,7 +6,7 @@ using MediaBrowser.Model.Events; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -namespace MediaBrowser.Api.ScheduledTasks +namespace Jellyfin.Api.WebSocketListeners { /// <summary> /// Class ScheduledTasksWebSocketListener. @@ -17,42 +17,27 @@ namespace MediaBrowser.Api.ScheduledTasks /// Gets or sets the task manager. /// </summary> /// <value>The task manager.</value> - private ITaskManager TaskManager { get; set; } + private readonly ITaskManager _taskManager; /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - protected override string Name => "ScheduledTasksInfo"; - - /// <summary> - /// Initializes a new instance of the <see cref="ScheduledTasksWebSocketListener" /> class. + /// Initializes a new instance of the <see cref="ScheduledTasksWebSocketListener"/> class. /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{ScheduledTasksWebSocketListener}"/> interface.</param> + /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> public ScheduledTasksWebSocketListener(ILogger<ScheduledTasksWebSocketListener> logger, ITaskManager taskManager) : base(logger) { - TaskManager = taskManager; + _taskManager = taskManager; - TaskManager.TaskExecuting += TaskManager_TaskExecuting; - TaskManager.TaskCompleted += TaskManager_TaskCompleted; + _taskManager.TaskExecuting += OnTaskExecuting; + _taskManager.TaskCompleted += OnTaskCompleted; } - void TaskManager_TaskCompleted(object sender, TaskCompletionEventArgs e) - { - SendData(true); - e.Task.TaskProgress -= Argument_TaskProgress; - } - - void TaskManager_TaskExecuting(object sender, GenericEventArgs<IScheduledTaskWorker> e) - { - SendData(true); - e.Argument.TaskProgress += Argument_TaskProgress; - } - - void Argument_TaskProgress(object sender, GenericEventArgs<double> e) - { - SendData(false); - } + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + protected override string Name => "ScheduledTasksInfo"; /// <summary> /// Gets the data to send. @@ -60,18 +45,36 @@ namespace MediaBrowser.Api.ScheduledTasks /// <returns>Task{IEnumerable{TaskInfo}}.</returns> protected override Task<IEnumerable<TaskInfo>> GetDataToSend() { - return Task.FromResult(TaskManager.ScheduledTasks + return Task.FromResult(_taskManager.ScheduledTasks .OrderBy(i => i.Name) .Select(ScheduledTaskHelpers.GetTaskInfo) .Where(i => !i.IsHidden)); } + /// <inheritdoc /> protected override void Dispose(bool dispose) { - TaskManager.TaskExecuting -= TaskManager_TaskExecuting; - TaskManager.TaskCompleted -= TaskManager_TaskCompleted; + _taskManager.TaskExecuting -= OnTaskExecuting; + _taskManager.TaskCompleted -= OnTaskCompleted; base.Dispose(dispose); } + + private void OnTaskCompleted(object sender, TaskCompletionEventArgs e) + { + SendData(true); + e.Task.TaskProgress -= OnTaskProgress; + } + + private void OnTaskExecuting(object sender, GenericEventArgs<IScheduledTaskWorker> e) + { + SendData(true); + e.Argument.TaskProgress += OnTaskProgress; + } + + private void OnTaskProgress(object sender, GenericEventArgs<double> e) + { + SendData(false); + } } } diff --git a/MediaBrowser.Api/Sessions/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs index 2400d6def..1fb5dc412 100644 --- a/MediaBrowser.Api/Sessions/SessionInfoWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs @@ -5,27 +5,20 @@ using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using Microsoft.Extensions.Logging; -namespace MediaBrowser.Api.Sessions +namespace Jellyfin.Api.WebSocketListeners { /// <summary> /// Class SessionInfoWebSocketListener. /// </summary> public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState> { - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - protected override string Name => "Sessions"; - - /// <summary> - /// The _kernel. - /// </summary> private readonly ISessionManager _sessionManager; /// <summary> /// Initializes a new instance of the <see cref="SessionInfoWebSocketListener"/> class. /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{SessionInfoWebSocketListener}"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> public SessionInfoWebSocketListener(ILogger<SessionInfoWebSocketListener> logger, ISessionManager sessionManager) : base(logger) { @@ -40,6 +33,32 @@ namespace MediaBrowser.Api.Sessions _sessionManager.SessionActivity += OnSessionManagerSessionActivity; } + /// <inheritdoc /> + protected override string Name => "Sessions"; + + /// <summary> + /// Gets the data to send. + /// </summary> + /// <returns>Task{SystemInfo}.</returns> + protected override Task<IEnumerable<SessionInfo>> GetDataToSend() + { + return Task.FromResult(_sessionManager.Sessions); + } + + /// <inheritdoc /> + protected override void Dispose(bool dispose) + { + _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; + _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; + _sessionManager.PlaybackStart -= OnSessionManagerPlaybackStart; + _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; + _sessionManager.PlaybackProgress -= OnSessionManagerPlaybackProgress; + _sessionManager.CapabilitiesChanged -= OnSessionManagerCapabilitiesChanged; + _sessionManager.SessionActivity -= OnSessionManagerSessionActivity; + + base.Dispose(dispose); + } + private async void OnSessionManagerSessionActivity(object sender, SessionEventArgs e) { await SendData(false).ConfigureAwait(false); @@ -74,28 +93,5 @@ namespace MediaBrowser.Api.Sessions { await SendData(true).ConfigureAwait(false); } - - /// <summary> - /// Gets the data to send. - /// </summary> - /// <returns>Task{SystemInfo}.</returns> - protected override Task<IEnumerable<SessionInfo>> GetDataToSend() - { - return Task.FromResult(_sessionManager.Sessions); - } - - /// <inheritdoc /> - protected override void Dispose(bool dispose) - { - _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; - _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; - _sessionManager.PlaybackStart -= OnSessionManagerPlaybackStart; - _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; - _sessionManager.PlaybackProgress -= OnSessionManagerPlaybackProgress; - _sessionManager.CapabilitiesChanged -= OnSessionManagerCapabilitiesChanged; - _sessionManager.SessionActivity -= OnSessionManagerSessionActivity; - - base.Dispose(dispose); - } } } diff --git a/Jellyfin.Data/Entities/DisplayPreferences.cs b/Jellyfin.Data/Entities/DisplayPreferences.cs new file mode 100644 index 000000000..cda83f6bb --- /dev/null +++ b/Jellyfin.Data/Entities/DisplayPreferences.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Jellyfin.Data.Enums; + +namespace Jellyfin.Data.Entities +{ + /// <summary> + /// An entity representing a user's display preferences. + /// </summary> + public class DisplayPreferences + { + /// <summary> + /// Initializes a new instance of the <see cref="DisplayPreferences"/> class. + /// </summary> + /// <param name="userId">The user's id.</param> + /// <param name="client">The client string.</param> + public DisplayPreferences(Guid userId, string client) + { + UserId = userId; + Client = client; + ShowSidebar = false; + ShowBackdrop = true; + SkipForwardLength = 30000; + SkipBackwardLength = 10000; + ScrollDirection = ScrollDirection.Horizontal; + ChromecastVersion = ChromecastVersion.Stable; + DashboardTheme = string.Empty; + TvHome = string.Empty; + + HomeSections = new HashSet<HomeSection>(); + } + + /// <summary> + /// Initializes a new instance of the <see cref="DisplayPreferences"/> class. + /// </summary> + protected DisplayPreferences() + { + } + + /// <summary> + /// Gets or sets the Id. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; protected set; } + + /// <summary> + /// Gets or sets the user Id. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets the client string. + /// </summary> + /// <remarks> + /// Required. Max Length = 32. + /// </remarks> + [Required] + [MaxLength(32)] + [StringLength(32)] + public string Client { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to show the sidebar. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public bool ShowSidebar { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to show the backdrop. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public bool ShowBackdrop { get; set; } + + /// <summary> + /// Gets or sets the scroll direction. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public ScrollDirection ScrollDirection { get; set; } + + /// <summary> + /// Gets or sets what the view should be indexed by. + /// </summary> + public IndexingKind? IndexBy { get; set; } + + /// <summary> + /// Gets or sets the length of time to skip forwards, in milliseconds. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int SkipForwardLength { get; set; } + + /// <summary> + /// Gets or sets the length of time to skip backwards, in milliseconds. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int SkipBackwardLength { get; set; } + + /// <summary> + /// Gets or sets the Chromecast Version. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public ChromecastVersion ChromecastVersion { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the next video info overlay should be shown. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public bool EnableNextVideoInfoOverlay { get; set; } + + /// <summary> + /// Gets or sets the dashboard theme. + /// </summary> + [MaxLength(32)] + [StringLength(32)] + public string DashboardTheme { get; set; } + + /// <summary> + /// Gets or sets the tv home screen. + /// </summary> + [MaxLength(32)] + [StringLength(32)] + public string TvHome { get; set; } + + /// <summary> + /// Gets or sets the home sections. + /// </summary> + public virtual ICollection<HomeSection> HomeSections { get; protected set; } + } +} diff --git a/Jellyfin.Data/Entities/HomeSection.cs b/Jellyfin.Data/Entities/HomeSection.cs new file mode 100644 index 000000000..062046260 --- /dev/null +++ b/Jellyfin.Data/Entities/HomeSection.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Jellyfin.Data.Enums; + +namespace Jellyfin.Data.Entities +{ + /// <summary> + /// An entity representing a section on the user's home page. + /// </summary> + public class HomeSection + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <remarks> + /// Identity. Required. + /// </remarks> + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; protected set; } + + /// <summary> + /// Gets or sets the Id of the associated display preferences. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int DisplayPreferencesId { get; set; } + + /// <summary> + /// Gets or sets the order. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int Order { get; set; } + + /// <summary> + /// Gets or sets the type. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public HomeSectionType Type { get; set; } + } +} diff --git a/Jellyfin.Data/Entities/ItemDisplayPreferences.cs b/Jellyfin.Data/Entities/ItemDisplayPreferences.cs new file mode 100644 index 000000000..95c08e6c6 --- /dev/null +++ b/Jellyfin.Data/Entities/ItemDisplayPreferences.cs @@ -0,0 +1,120 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Jellyfin.Data.Enums; + +namespace Jellyfin.Data.Entities +{ + public class ItemDisplayPreferences + { + /// <summary> + /// Initializes a new instance of the <see cref="ItemDisplayPreferences"/> class. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="client">The client.</param> + public ItemDisplayPreferences(Guid userId, Guid itemId, string client) + { + UserId = userId; + ItemId = itemId; + Client = client; + + SortBy = "SortName"; + ViewType = ViewType.Poster; + SortOrder = SortOrder.Ascending; + RememberSorting = false; + RememberIndexing = false; + } + + /// <summary> + /// Initializes a new instance of the <see cref="ItemDisplayPreferences"/> class. + /// </summary> + protected ItemDisplayPreferences() + { + } + + /// <summary> + /// Gets or sets the Id. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; protected set; } + + /// <summary> + /// Gets or sets the user Id. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets the id of the associated item. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public Guid ItemId { get; set; } + + /// <summary> + /// Gets or sets the client string. + /// </summary> + /// <remarks> + /// Required. Max Length = 32. + /// </remarks> + [Required] + [MaxLength(32)] + [StringLength(32)] + public string Client { get; set; } + + /// <summary> + /// Gets or sets the view type. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public ViewType ViewType { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the indexing should be remembered. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public bool RememberIndexing { get; set; } + + /// <summary> + /// Gets or sets what the view should be indexed by. + /// </summary> + public IndexingKind? IndexBy { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the sorting type should be remembered. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public bool RememberSorting { get; set; } + + /// <summary> + /// Gets or sets what the view should be sorted by. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + [Required] + [MaxLength(64)] + [StringLength(64)] + public string SortBy { get; set; } + + /// <summary> + /// Gets or sets the sort order. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public SortOrder SortOrder { get; set; } + } +} diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs index b89b0a8f4..50810561f 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/Jellyfin.Data/Entities/User.cs @@ -48,6 +48,7 @@ namespace Jellyfin.Data.Entities PasswordResetProviderId = passwordResetProviderId; AccessSchedules = new HashSet<AccessSchedule>(); + ItemDisplayPreferences = new HashSet<ItemDisplayPreferences>(); // Groups = new HashSet<Group>(); Permissions = new HashSet<Permission>(); Preferences = new HashSet<Preference>(); @@ -327,6 +328,15 @@ namespace Jellyfin.Data.Entities // [ForeignKey("UserId")] public virtual ImageInfo ProfileImage { get; set; } + /// <summary> + /// Gets or sets the user's display preferences. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + [Required] + public virtual DisplayPreferences DisplayPreferences { get; set; } + [Required] public SyncPlayAccess SyncPlayAccess { get; set; } @@ -349,6 +359,11 @@ namespace Jellyfin.Data.Entities /// </summary> public virtual ICollection<AccessSchedule> AccessSchedules { get; protected set; } + /// <summary> + /// Gets or sets the list of item display preferences. + /// </summary> + public virtual ICollection<ItemDisplayPreferences> ItemDisplayPreferences { get; protected set; } + /* /// <summary> /// Gets or sets the list of groups this user is a member of. diff --git a/Jellyfin.Data/Enums/ChromecastVersion.cs b/Jellyfin.Data/Enums/ChromecastVersion.cs new file mode 100644 index 000000000..2622e08ef --- /dev/null +++ b/Jellyfin.Data/Enums/ChromecastVersion.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Data.Enums +{ + /// <summary> + /// An enum representing the version of Chromecast to be used by clients. + /// </summary> + public enum ChromecastVersion + { + /// <summary> + /// Stable Chromecast version. + /// </summary> + Stable = 0, + + /// <summary> + /// Unstable Chromecast version. + /// </summary> + Unstable = 1 + } +} diff --git a/Jellyfin.Data/Enums/HomeSectionType.cs b/Jellyfin.Data/Enums/HomeSectionType.cs new file mode 100644 index 000000000..e597c9431 --- /dev/null +++ b/Jellyfin.Data/Enums/HomeSectionType.cs @@ -0,0 +1,53 @@ +namespace Jellyfin.Data.Enums +{ + /// <summary> + /// An enum representing the different options for the home screen sections. + /// </summary> + public enum HomeSectionType + { + /// <summary> + /// None. + /// </summary> + None = 0, + + /// <summary> + /// My Media. + /// </summary> + SmallLibraryTiles = 1, + + /// <summary> + /// My Media Small. + /// </summary> + LibraryButtons = 2, + + /// <summary> + /// Active Recordings. + /// </summary> + ActiveRecordings = 3, + + /// <summary> + /// Continue Watching. + /// </summary> + Resume = 4, + + /// <summary> + /// Continue Listening. + /// </summary> + ResumeAudio = 5, + + /// <summary> + /// Latest Media. + /// </summary> + LatestMedia = 6, + + /// <summary> + /// Next Up. + /// </summary> + NextUp = 7, + + /// <summary> + /// Live TV. + /// </summary> + LiveTv = 8 + } +} diff --git a/Jellyfin.Data/Enums/IndexingKind.cs b/Jellyfin.Data/Enums/IndexingKind.cs new file mode 100644 index 000000000..9badc6573 --- /dev/null +++ b/Jellyfin.Data/Enums/IndexingKind.cs @@ -0,0 +1,20 @@ +namespace Jellyfin.Data.Enums +{ + public enum IndexingKind + { + /// <summary> + /// Index by the premiere date. + /// </summary> + PremiereDate = 0, + + /// <summary> + /// Index by the production year. + /// </summary> + ProductionYear = 1, + + /// <summary> + /// Index by the community rating. + /// </summary> + CommunityRating = 2 + } +} diff --git a/Jellyfin.Data/Enums/ScrollDirection.cs b/Jellyfin.Data/Enums/ScrollDirection.cs new file mode 100644 index 000000000..9595eb490 --- /dev/null +++ b/Jellyfin.Data/Enums/ScrollDirection.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Data.Enums +{ + /// <summary> + /// An enum representing the axis that should be scrolled. + /// </summary> + public enum ScrollDirection + { + /// <summary> + /// Horizontal scrolling direction. + /// </summary> + Horizontal = 0, + + /// <summary> + /// Vertical scrolling direction. + /// </summary> + Vertical = 1 + } +} diff --git a/Jellyfin.Data/Enums/SortOrder.cs b/Jellyfin.Data/Enums/SortOrder.cs new file mode 100644 index 000000000..760a857f5 --- /dev/null +++ b/Jellyfin.Data/Enums/SortOrder.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Data.Enums +{ + /// <summary> + /// An enum representing the sorting order. + /// </summary> + public enum SortOrder + { + /// <summary> + /// Sort in increasing order. + /// </summary> + Ascending = 0, + + /// <summary> + /// Sort in decreasing order. + /// </summary> + Descending = 1 + } +} diff --git a/Jellyfin.Data/Enums/ViewType.cs b/Jellyfin.Data/Enums/ViewType.cs new file mode 100644 index 000000000..595429ab1 --- /dev/null +++ b/Jellyfin.Data/Enums/ViewType.cs @@ -0,0 +1,38 @@ +namespace Jellyfin.Data.Enums +{ + /// <summary> + /// An enum representing the type of view for a library or collection. + /// </summary> + public enum ViewType + { + /// <summary> + /// Shows banners. + /// </summary> + Banner = 0, + + /// <summary> + /// Shows a list of content. + /// </summary> + List = 1, + + /// <summary> + /// Shows poster artwork. + /// </summary> + Poster = 2, + + /// <summary> + /// Shows poster artwork with a card containing the name and year. + /// </summary> + PosterCard = 3, + + /// <summary> + /// Shows a thumbnail. + /// </summary> + Thumb = 4, + + /// <summary> + /// Shows a thumbnail with a card containing the name and year. + /// </summary> + ThumbCard = 5 + } +} diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index 6db514b2e..c71c76f08 100644 --- a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -18,11 +18,10 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="BlurHashSharp" Version="1.0.1" /> - <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.0.0" /> - <PackageReference Include="SkiaSharp" Version="1.68.3" /> - <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="1.68.3" /> - <PackageReference Include="Jellyfin.SkiaSharp.NativeAssets.LinuxArm" Version="1.68.1" /> + <PackageReference Include="BlurHashSharp" Version="1.1.0" /> + <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.1.0" /> + <PackageReference Include="SkiaSharp" Version="2.80.1" /> + <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.80.1" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs b/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs index f2df066ec..6136a2ff9 100644 --- a/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs +++ b/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs @@ -19,22 +19,18 @@ namespace Jellyfin.Drawing.Skia /// <param name="percent">The percentage played to display with the indicator.</param> public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent) { - using (var paint = new SKPaint()) - { - var endX = imageSize.Width - 1; - var endY = imageSize.Height - 1; + using var paint = new SKPaint(); + var endX = imageSize.Width - 1; + var endY = imageSize.Height - 1; - paint.Color = SKColor.Parse("#99000000"); - paint.Style = SKPaintStyle.Fill; - canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, (float)endX, (float)endY), paint); + paint.Color = SKColor.Parse("#99000000"); + paint.Style = SKPaintStyle.Fill; + canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint); - double foregroundWidth = endX; - foregroundWidth *= percent; - foregroundWidth /= 100; + double foregroundWidth = (endX * percent) / 100; - paint.Color = SKColor.Parse("#FF00A4DC"); - canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), (float)endY), paint); - } + paint.Color = SKColor.Parse("#FF00A4DC"); + canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint); } } } diff --git a/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs b/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs index 7eed5f4f7..db4f78398 100644 --- a/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs +++ b/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs @@ -22,31 +22,27 @@ namespace Jellyfin.Drawing.Skia { var x = imageSize.Width - OffsetFromTopRightCorner; - using (var paint = new SKPaint()) + using var paint = new SKPaint { - paint.Color = SKColor.Parse("#CC00A4DC"); - paint.Style = SKPaintStyle.Fill; - canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); - } + Color = SKColor.Parse("#CC00A4DC"), + Style = SKPaintStyle.Fill + }; - using (var paint = new SKPaint()) - { - paint.Color = new SKColor(255, 255, 255, 255); - paint.Style = SKPaintStyle.Fill; + canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); - paint.TextSize = 30; - paint.IsAntialias = true; + paint.Color = new SKColor(255, 255, 255, 255); + paint.TextSize = 30; + paint.IsAntialias = true; - // or: - // var emojiChar = 0x1F680; - const string Text = "✔️"; - var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32); + // or: + // var emojiChar = 0x1F680; + const string Text = "✔️"; + var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32); - // ask the font manager for a font with that character - paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar); + // ask the font manager for a font with that character + paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar); - canvas.DrawText(Text, (float)x - 20, OffsetFromTopRightCorner + 12, paint); - } + canvas.DrawText(Text, (float)x - 20, OffsetFromTopRightCorner + 12, paint); } } } diff --git a/Jellyfin.Drawing.Skia/SkiaCodecException.cs b/Jellyfin.Drawing.Skia/SkiaCodecException.cs index 1d2db5515..9a50a4d62 100644 --- a/Jellyfin.Drawing.Skia/SkiaCodecException.cs +++ b/Jellyfin.Drawing.Skia/SkiaCodecException.cs @@ -12,7 +12,7 @@ namespace Jellyfin.Drawing.Skia /// Initializes a new instance of the <see cref="SkiaCodecException" /> class. /// </summary> /// <param name="result">The non-successful codec result returned by Skia.</param> - public SkiaCodecException(SKCodecResult result) : base() + public SkiaCodecException(SKCodecResult result) { CodecResult = result; } diff --git a/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/Jellyfin.Drawing.Skia/SkiaEncoder.cs index ba9a5809f..a1caa751b 100644 --- a/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -29,9 +29,7 @@ namespace Jellyfin.Drawing.Skia /// </summary> /// <param name="logger">The application logger.</param> /// <param name="appPaths">The application paths.</param> - public SkiaEncoder( - ILogger<SkiaEncoder> logger, - IApplicationPaths appPaths) + public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths) { _logger = logger; _appPaths = appPaths; @@ -102,19 +100,14 @@ namespace Jellyfin.Drawing.Skia /// <returns>The converted format.</returns> public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat) { - switch (selectedFormat) - { - case ImageFormat.Bmp: - return SKEncodedImageFormat.Bmp; - case ImageFormat.Jpg: - return SKEncodedImageFormat.Jpeg; - case ImageFormat.Gif: - return SKEncodedImageFormat.Gif; - case ImageFormat.Webp: - return SKEncodedImageFormat.Webp; - default: - return SKEncodedImageFormat.Png; - } + return selectedFormat switch + { + ImageFormat.Bmp => SKEncodedImageFormat.Bmp, + ImageFormat.Jpg => SKEncodedImageFormat.Jpeg, + ImageFormat.Gif => SKEncodedImageFormat.Gif, + ImageFormat.Webp => SKEncodedImageFormat.Webp, + _ => SKEncodedImageFormat.Png + }; } private static bool IsTransparentRow(SKBitmap bmp, int row) @@ -146,63 +139,34 @@ namespace Jellyfin.Drawing.Skia private SKBitmap CropWhiteSpace(SKBitmap bitmap) { var topmost = 0; - for (int row = 0; row < bitmap.Height; ++row) + while (topmost < bitmap.Height && IsTransparentRow(bitmap, topmost)) { - if (IsTransparentRow(bitmap, row)) - { - topmost = row + 1; - } - else - { - break; - } + topmost++; } int bottommost = bitmap.Height; - for (int row = bitmap.Height - 1; row >= 0; --row) + while (bottommost >= 0 && IsTransparentRow(bitmap, bottommost - 1)) { - if (IsTransparentRow(bitmap, row)) - { - bottommost = row; - } - else - { - break; - } + bottommost--; } - int leftmost = 0, rightmost = bitmap.Width; - for (int col = 0; col < bitmap.Width; ++col) + var leftmost = 0; + while (leftmost < bitmap.Width && IsTransparentColumn(bitmap, leftmost)) { - if (IsTransparentColumn(bitmap, col)) - { - leftmost = col + 1; - } - else - { - break; - } + leftmost++; } - for (int col = bitmap.Width - 1; col >= 0; --col) + var rightmost = bitmap.Width; + while (rightmost >= 0 && IsTransparentColumn(bitmap, rightmost - 1)) { - if (IsTransparentColumn(bitmap, col)) - { - rightmost = col; - } - else - { - break; - } + rightmost--; } var newRect = SKRectI.Create(leftmost, topmost, rightmost - leftmost, bottommost - topmost); - using (var image = SKImage.FromBitmap(bitmap)) - using (var subset = image.Subset(newRect)) - { - return SKBitmap.FromImage(subset); - } + using var image = SKImage.FromBitmap(bitmap); + using var subset = image.Subset(newRect); + return SKBitmap.FromImage(subset); } /// <inheritdoc /> @@ -216,14 +180,12 @@ namespace Jellyfin.Drawing.Skia throw new FileNotFoundException("File not found", path); } - using (var codec = SKCodec.Create(path, out SKCodecResult result)) - { - EnsureSuccess(result); + using var codec = SKCodec.Create(path, out SKCodecResult result); + EnsureSuccess(result); - var info = codec.Info; + var info = codec.Info; - return new ImageDimensions(info.Width, info.Height); - } + return new ImageDimensions(info.Width, info.Height); } /// <inheritdoc /> @@ -237,7 +199,8 @@ namespace Jellyfin.Drawing.Skia throw new ArgumentNullException(nameof(path)); } - return BlurHashEncoder.Encode(xComp, yComp, path); + // Any larger than 128x128 is too slow and there's no visually discernible difference + return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128); } private static bool HasDiacritics(string text) @@ -253,12 +216,7 @@ namespace Jellyfin.Drawing.Skia } } - if (HasDiacritics(path)) - { - return true; - } - - return false; + return HasDiacritics(path); } private string NormalizePath(string path) @@ -283,25 +241,17 @@ namespace Jellyfin.Drawing.Skia return SKEncodedOrigin.TopLeft; } - switch (orientation.Value) - { - case ImageOrientation.TopRight: - return SKEncodedOrigin.TopRight; - case ImageOrientation.RightTop: - return SKEncodedOrigin.RightTop; - case ImageOrientation.RightBottom: - return SKEncodedOrigin.RightBottom; - case ImageOrientation.LeftTop: - return SKEncodedOrigin.LeftTop; - case ImageOrientation.LeftBottom: - return SKEncodedOrigin.LeftBottom; - case ImageOrientation.BottomRight: - return SKEncodedOrigin.BottomRight; - case ImageOrientation.BottomLeft: - return SKEncodedOrigin.BottomLeft; - default: - return SKEncodedOrigin.TopLeft; - } + return orientation.Value switch + { + ImageOrientation.TopRight => SKEncodedOrigin.TopRight, + ImageOrientation.RightTop => SKEncodedOrigin.RightTop, + ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom, + ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop, + ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom, + ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight, + ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft, + _ => SKEncodedOrigin.TopLeft + }; } /// <summary> @@ -323,24 +273,22 @@ namespace Jellyfin.Drawing.Skia if (requiresTransparencyHack || forceCleanBitmap) { - using (var codec = SKCodec.Create(NormalizePath(path))) + using var codec = SKCodec.Create(NormalizePath(path)); + if (codec == null) { - if (codec == null) - { - origin = GetSKEncodedOrigin(orientation); - return null; - } + origin = GetSKEncodedOrigin(orientation); + return null; + } - // create the bitmap - var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); + // create the bitmap + var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); - // decode - _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels()); + // decode + _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels()); - origin = codec.EncodedOrigin; + origin = codec.EncodedOrigin; - return bitmap; - } + return bitmap; } var resultBitmap = SKBitmap.Decode(NormalizePath(path)); @@ -367,15 +315,8 @@ namespace Jellyfin.Drawing.Skia { if (cropWhitespace) { - using (var bitmap = Decode(path, forceAnalyzeBitmap, orientation, out origin)) - { - if (bitmap == null) - { - return null; - } - - return CropWhiteSpace(bitmap); - } + using var bitmap = Decode(path, forceAnalyzeBitmap, orientation, out origin); + return bitmap == null ? null : CropWhiteSpace(bitmap); } return Decode(path, forceAnalyzeBitmap, orientation, out origin); @@ -403,133 +344,105 @@ namespace Jellyfin.Drawing.Skia private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) { + if (origin == SKEncodedOrigin.Default) + { + return bitmap; + } + + var needsFlip = origin == SKEncodedOrigin.LeftBottom + || origin == SKEncodedOrigin.LeftTop + || origin == SKEncodedOrigin.RightBottom + || origin == SKEncodedOrigin.RightTop; + var rotated = needsFlip + ? new SKBitmap(bitmap.Height, bitmap.Width) + : new SKBitmap(bitmap.Width, bitmap.Height); + using var surface = new SKCanvas(rotated); + var midX = (float)rotated.Width / 2; + var midY = (float)rotated.Height / 2; + switch (origin) { case SKEncodedOrigin.TopRight: - { - var rotated = new SKBitmap(bitmap.Width, bitmap.Height); - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(rotated.Width, 0); - surface.Scale(-1, 1); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - + surface.Scale(-1, 1, midX, midY); + break; case SKEncodedOrigin.BottomRight: - { - var rotated = new SKBitmap(bitmap.Width, bitmap.Height); - using (var surface = new SKCanvas(rotated)) - { - float px = (float)bitmap.Width / 2; - float py = (float)bitmap.Height / 2; - - surface.RotateDegrees(180, px, py); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - + surface.RotateDegrees(180, midX, midY); + break; case SKEncodedOrigin.BottomLeft: - { - var rotated = new SKBitmap(bitmap.Width, bitmap.Height); - using (var surface = new SKCanvas(rotated)) - { - float px = (float)bitmap.Width / 2; - - float py = (float)bitmap.Height / 2; - - surface.Translate(rotated.Width, 0); - surface.Scale(-1, 1); - - surface.RotateDegrees(180, px, py); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - + surface.Scale(1, -1, midX, midY); + break; case SKEncodedOrigin.LeftTop: - { - // TODO: Remove dual canvases, had trouble with flipping - using (var rotated = new SKBitmap(bitmap.Height, bitmap.Width)) - { - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(rotated.Width, 0); - - surface.RotateDegrees(90); - - surface.DrawBitmap(bitmap, 0, 0); - } - - var flippedBitmap = new SKBitmap(rotated.Width, rotated.Height); - using (var flippedCanvas = new SKCanvas(flippedBitmap)) - { - flippedCanvas.Translate(flippedBitmap.Width, 0); - flippedCanvas.Scale(-1, 1); - flippedCanvas.DrawBitmap(rotated, 0, 0); - } - - return flippedBitmap; - } - } - + surface.Translate(0, -rotated.Height); + surface.Scale(1, -1, midX, midY); + surface.RotateDegrees(-90); + break; case SKEncodedOrigin.RightTop: - { - var rotated = new SKBitmap(bitmap.Height, bitmap.Width); - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(rotated.Width, 0); - surface.RotateDegrees(90); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - + surface.Translate(rotated.Width, 0); + surface.RotateDegrees(90); + break; case SKEncodedOrigin.RightBottom: - { - // TODO: Remove dual canvases, had trouble with flipping - using (var rotated = new SKBitmap(bitmap.Height, bitmap.Width)) - { - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(0, rotated.Height); - surface.RotateDegrees(270); - surface.DrawBitmap(bitmap, 0, 0); - } - - var flippedBitmap = new SKBitmap(rotated.Width, rotated.Height); - using (var flippedCanvas = new SKCanvas(flippedBitmap)) - { - flippedCanvas.Translate(flippedBitmap.Width, 0); - flippedCanvas.Scale(-1, 1); - flippedCanvas.DrawBitmap(rotated, 0, 0); - } - - return flippedBitmap; - } - } - + surface.Translate(rotated.Width, 0); + surface.Scale(1, -1, midX, midY); + surface.RotateDegrees(90); + break; case SKEncodedOrigin.LeftBottom: - { - var rotated = new SKBitmap(bitmap.Height, bitmap.Width); - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(0, rotated.Height); - surface.RotateDegrees(270); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - - default: return bitmap; + surface.Translate(0, rotated.Height); + surface.RotateDegrees(-90); + break; } + + surface.DrawBitmap(bitmap, 0, 0); + return rotated; + } + + /// <summary> + /// Resizes an image on the CPU, by utilizing a surface and canvas. + /// + /// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect. + /// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP](https://docs.gimp.org/2.10/en/gimp-filter-convolution-matrix.html). + /// </summary> + /// <param name="source">The source bitmap.</param> + /// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param> + /// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param> + /// <param name="isDither">This enables dithering on the SKPaint instance.</param> + /// <returns>The resized image.</returns> + internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false) + { + using var surface = SKSurface.Create(targetInfo); + using var canvas = surface.Canvas; + using var paint = new SKPaint + { + FilterQuality = SKFilterQuality.High, + IsAntialias = isAntialias, + IsDither = isDither + }; + + var kernel = new float[9] + { + 0, -.1f, 0, + -.1f, 1.4f, -.1f, + 0, -.1f, 0, + }; + + var kernelSize = new SKSizeI(3, 3); + var kernelOffset = new SKPointI(1, 1); + + paint.ImageFilter = SKImageFilter.CreateMatrixConvolution( + kernelSize, + kernel, + 1f, + 0f, + kernelOffset, + SKShaderTileMode.Clamp, + false); + + canvas.DrawBitmap( + source, + SKRect.Create(0, 0, source.Width, source.Height), + SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height), + paint); + + return surface.Snapshot(); } /// <inheritdoc/> @@ -552,97 +465,87 @@ namespace Jellyfin.Drawing.Skia var blur = options.Blur ?? 0; var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); - using (var bitmap = GetBitmap(inputPath, options.CropWhiteSpace, autoOrient, orientation)) + using var bitmap = GetBitmap(inputPath, options.CropWhiteSpace, autoOrient, orientation); + if (bitmap == null) { - if (bitmap == null) - { - throw new InvalidDataException($"Skia unable to read image {inputPath}"); - } + throw new InvalidDataException($"Skia unable to read image {inputPath}"); + } - var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height); + var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height); - if (!options.CropWhiteSpace - && options.HasDefaultOptions(inputPath, originalImageSize) - && !autoOrient) - { - // Just spit out the original file if all the options are default - return inputPath; - } + if (!options.CropWhiteSpace + && options.HasDefaultOptions(inputPath, originalImageSize) + && !autoOrient) + { + // Just spit out the original file if all the options are default + return inputPath; + } + + var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize); + + var width = newImageSize.Width; + var height = newImageSize.Height; - var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize); + // scale image (the FromImage creates a copy) + var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace); + using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo)); - var width = newImageSize.Width; - var height = newImageSize.Height; + // If all we're doing is resizing then we can stop now + if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + using var outputStream = new SKFileWStream(outputPath); + using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels()); + resizedBitmap.Encode(outputStream, skiaOutputFormat, quality); + return outputPath; + } + + // create bitmap to use for canvas drawing used to draw into bitmap + using var saveBitmap = new SKBitmap(width, height); + using var canvas = new SKCanvas(saveBitmap); + // set background color if present + if (hasBackgroundColor) + { + canvas.Clear(SKColor.Parse(options.BackgroundColor)); + } - using (var resizedBitmap = new SKBitmap(width, height, bitmap.ColorType, bitmap.AlphaType)) + // Add blur if option is present + if (blur > 0) + { + // create image from resized bitmap to apply blur + using var paint = new SKPaint(); + using var filter = SKImageFilter.CreateBlur(blur, blur); + paint.ImageFilter = filter; + canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint); + } + else + { + // draw resized bitmap onto canvas + canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height)); + } + + // If foreground layer present then draw + if (hasForegroundColor) + { + if (!double.TryParse(options.ForegroundLayer, out double opacity)) { - // scale image - bitmap.ScalePixels(resizedBitmap, SKFilterQuality.High); + opacity = .4; + } - // If all we're doing is resizing then we can stop now - if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) - { - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); - using (var outputStream = new SKFileWStream(outputPath)) - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels())) - { - pixmap.Encode(outputStream, skiaOutputFormat, quality); - return outputPath; - } - } + canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver); + } - // create bitmap to use for canvas drawing used to draw into bitmap - using (var saveBitmap = new SKBitmap(width, height)) // , bitmap.ColorType, bitmap.AlphaType)) - using (var canvas = new SKCanvas(saveBitmap)) - { - // set background color if present - if (hasBackgroundColor) - { - canvas.Clear(SKColor.Parse(options.BackgroundColor)); - } - - // Add blur if option is present - if (blur > 0) - { - // create image from resized bitmap to apply blur - using (var paint = new SKPaint()) - using (var filter = SKImageFilter.CreateBlur(blur, blur)) - { - paint.ImageFilter = filter; - canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint); - } - } - else - { - // draw resized bitmap onto canvas - canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height)); - } - - // If foreground layer present then draw - if (hasForegroundColor) - { - if (!double.TryParse(options.ForegroundLayer, out double opacity)) - { - opacity = .4; - } - - canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver); - } - - if (hasIndicator) - { - DrawIndicator(canvas, width, height, options); - } - - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); - using (var outputStream = new SKFileWStream(outputPath)) - { - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels())) - { - pixmap.Encode(outputStream, skiaOutputFormat, quality); - } - } - } + if (hasIndicator) + { + DrawIndicator(canvas, width, height, options); + } + + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + using (var outputStream = new SKFileWStream(outputPath)) + { + using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels())) + { + pixmap.Encode(outputStream, skiaOutputFormat, quality); } } diff --git a/Jellyfin.Drawing.Skia/SkiaException.cs b/Jellyfin.Drawing.Skia/SkiaException.cs index 968d3a244..5b272eac5 100644 --- a/Jellyfin.Drawing.Skia/SkiaException.cs +++ b/Jellyfin.Drawing.Skia/SkiaException.cs @@ -10,7 +10,7 @@ namespace Jellyfin.Drawing.Skia /// <summary> /// Initializes a new instance of the <see cref="SkiaException"/> class. /// </summary> - public SkiaException() : base() + public SkiaException() { } diff --git a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs index 61bef90ec..10bb59648 100644 --- a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -69,12 +69,10 @@ namespace Jellyfin.Drawing.Skia /// <param name="height">The desired height of the collage.</param> public void BuildSquareCollage(string[] paths, string outputPath, int width, int height) { - using (var bitmap = BuildSquareCollageBitmap(paths, width, height)) - using (var outputStream = new SKFileWStream(outputPath)) - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels())) - { - pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); - } + using var bitmap = BuildSquareCollageBitmap(paths, width, height); + using var outputStream = new SKFileWStream(outputPath); + using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels()); + pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); } /// <summary> @@ -86,56 +84,44 @@ namespace Jellyfin.Drawing.Skia /// <param name="height">The desired height of the collage.</param> public void BuildThumbCollage(string[] paths, string outputPath, int width, int height) { - using (var bitmap = BuildThumbCollageBitmap(paths, width, height)) - using (var outputStream = new SKFileWStream(outputPath)) - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels())) - { - pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); - } + using var bitmap = BuildThumbCollageBitmap(paths, width, height); + using var outputStream = new SKFileWStream(outputPath); + using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels()); + pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); } private SKBitmap BuildThumbCollageBitmap(string[] paths, int width, int height) { var bitmap = new SKBitmap(width, height); - using (var canvas = new SKCanvas(bitmap)) - { - canvas.Clear(SKColors.Black); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Black); - // number of images used in the thumbnail - var iCount = 3; + // number of images used in the thumbnail + var iCount = 3; - // determine sizes for each image that will composited into the final image - var iSlice = Convert.ToInt32(width / iCount); - int iHeight = Convert.ToInt32(height * 1.00); - int imageIndex = 0; - for (int i = 0; i < iCount; i++) + // determine sizes for each image that will composited into the final image + var iSlice = Convert.ToInt32(width / iCount); + int iHeight = Convert.ToInt32(height * 1.00); + int imageIndex = 0; + for (int i = 0; i < iCount; i++) + { + using var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex); + imageIndex = newIndex; + if (currentBitmap == null) { - using (var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex)) - { - imageIndex = newIndex; - if (currentBitmap == null) - { - continue; - } - - // resize to the same aspect as the original - int iWidth = Math.Abs(iHeight * currentBitmap.Width / currentBitmap.Height); - using (var resizeBitmap = new SKBitmap(iWidth, iHeight, currentBitmap.ColorType, currentBitmap.AlphaType)) - { - currentBitmap.ScalePixels(resizeBitmap, SKFilterQuality.High); - - // crop image - int ix = Math.Abs((iWidth - iSlice) / 2); - using (var image = SKImage.FromBitmap(resizeBitmap)) - using (var subset = image.Subset(SKRectI.Create(ix, 0, iSlice, iHeight))) - { - // draw image onto canvas - canvas.DrawImage(subset ?? image, iSlice * i, 0); - } - } - } + continue; } + + // resize to the same aspect as the original + int iWidth = Math.Abs(iHeight * currentBitmap.Width / currentBitmap.Height); + using var resizedImage = SkiaEncoder.ResizeImage(currentBitmap, new SKImageInfo(iWidth, iHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace)); + + // crop image + int ix = Math.Abs((iWidth - iSlice) / 2); + using var subset = resizedImage.Subset(SKRectI.Create(ix, 0, iSlice, iHeight)); + // draw image onto canvas + canvas.DrawImage(subset ?? resizedImage, iSlice * i, 0); } return bitmap; @@ -176,33 +162,27 @@ namespace Jellyfin.Drawing.Skia var cellWidth = width / 2; var cellHeight = height / 2; - using (var canvas = new SKCanvas(bitmap)) + using var canvas = new SKCanvas(bitmap); + for (var x = 0; x < 2; x++) { - for (var x = 0; x < 2; x++) + for (var y = 0; y < 2; y++) { - for (var y = 0; y < 2; y++) + using var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex); + imageIndex = newIndex; + + if (currentBitmap == null) { - using (var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex)) - { - imageIndex = newIndex; - - if (currentBitmap == null) - { - continue; - } - - using (var resizedBitmap = new SKBitmap(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType)) - { - // scale image - currentBitmap.ScalePixels(resizedBitmap, SKFilterQuality.High); - - // draw this image into the strip at the next position - var xPos = x * cellWidth; - var yPos = y * cellHeight; - canvas.DrawBitmap(resizedBitmap, xPos, yPos); - } - } + continue; } + + // Scale image. The FromBitmap creates a copy + var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace); + using var resizedBitmap = SKBitmap.FromImage(SkiaEncoder.ResizeImage(currentBitmap, imageInfo)); + + // draw this image into the strip at the next position + var xPos = x * cellWidth; + var yPos = y * cellHeight; + canvas.DrawBitmap(resizedBitmap, xPos, yPos); } } diff --git a/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs b/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs index cf3dbde2c..58f887c96 100644 --- a/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs +++ b/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs @@ -28,41 +28,37 @@ namespace Jellyfin.Drawing.Skia var x = imageSize.Width - OffsetFromTopRightCorner; var text = count.ToString(CultureInfo.InvariantCulture); - using (var paint = new SKPaint()) + using var paint = new SKPaint { - paint.Color = SKColor.Parse("#CC00A4DC"); - paint.Style = SKPaintStyle.Fill; - canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); - } - - using (var paint = new SKPaint()) - { - paint.Color = new SKColor(255, 255, 255, 255); - paint.Style = SKPaintStyle.Fill; + Color = SKColor.Parse("#CC00A4DC"), + Style = SKPaintStyle.Fill + }; - paint.TextSize = 24; - paint.IsAntialias = true; + canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); - var y = OffsetFromTopRightCorner + 9; + paint.Color = new SKColor(255, 255, 255, 255); + paint.TextSize = 24; + paint.IsAntialias = true; - if (text.Length == 1) - { - x -= 7; - } + var y = OffsetFromTopRightCorner + 9; - if (text.Length == 2) - { - x -= 13; - } - else if (text.Length >= 3) - { - x -= 15; - y -= 2; - paint.TextSize = 18; - } + if (text.Length == 1) + { + x -= 7; + } - canvas.DrawText(text, x, y, paint); + if (text.Length == 2) + { + x -= 13; + } + else if (text.Length >= 3) + { + x -= 15; + y -= 2; + paint.TextSize = 18; } + + canvas.DrawText(text, x, y, paint); } } } diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 65ceee32b..2deefbe81 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -40,8 +40,9 @@ namespace Jellyfin.Server.Implementations.Activity /// <inheritdoc/> public async Task CreateAsync(ActivityLog entry) { - using var dbContext = _provider.CreateContext(); - await dbContext.ActivityLogs.AddAsync(entry); + await using var dbContext = _provider.CreateContext(); + + dbContext.ActivityLogs.Add(entry); await dbContext.SaveChangesAsync().ConfigureAwait(false); EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry))); diff --git a/Jellyfin.Server.Implementations/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs index acc6eec1c..7d864ebc6 100644 --- a/Jellyfin.Server.Implementations/JellyfinDb.cs +++ b/Jellyfin.Server.Implementations/JellyfinDb.cs @@ -28,8 +28,12 @@ namespace Jellyfin.Server.Implementations public virtual DbSet<ActivityLog> ActivityLogs { get; set; } + public virtual DbSet<DisplayPreferences> DisplayPreferences { get; set; } + public virtual DbSet<ImageInfo> ImageInfos { get; set; } + public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; } + public virtual DbSet<Permission> Permissions { get; set; } public virtual DbSet<Preference> Preferences { get; set; } diff --git a/Jellyfin.Server.Implementations/JellyfinDbProvider.cs b/Jellyfin.Server.Implementations/JellyfinDbProvider.cs index 8f5c19900..486be6053 100644 --- a/Jellyfin.Server.Implementations/JellyfinDbProvider.cs +++ b/Jellyfin.Server.Implementations/JellyfinDbProvider.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using MediaBrowser.Common.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -10,15 +12,20 @@ namespace Jellyfin.Server.Implementations public class JellyfinDbProvider { private readonly IServiceProvider _serviceProvider; + private readonly IApplicationPaths _appPaths; /// <summary> /// Initializes a new instance of the <see cref="JellyfinDbProvider"/> class. /// </summary> /// <param name="serviceProvider">The application's service provider.</param> - public JellyfinDbProvider(IServiceProvider serviceProvider) + /// <param name="appPaths">The application paths.</param> + public JellyfinDbProvider(IServiceProvider serviceProvider, IApplicationPaths appPaths) { _serviceProvider = serviceProvider; - serviceProvider.GetRequiredService<JellyfinDb>().Database.Migrate(); + _appPaths = appPaths; + + using var jellyfinDb = CreateContext(); + jellyfinDb.Database.Migrate(); } /// <summary> @@ -27,7 +34,8 @@ namespace Jellyfin.Server.Implementations /// <returns>The newly created context.</returns> public JellyfinDb CreateContext() { - return _serviceProvider.GetRequiredService<JellyfinDb>(); + var contextOptions = new DbContextOptionsBuilder<JellyfinDb>().UseSqlite($"Filename={Path.Combine(_appPaths.DataPath, "jellyfin.db")}"); + return ActivatorUtilities.CreateInstance<JellyfinDb>(_serviceProvider, contextOptions.Options); } } } diff --git a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs new file mode 100644 index 000000000..d44707d06 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs @@ -0,0 +1,459 @@ +#pragma warning disable CS1591 + +// <auto-generated /> +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDb))] + [Migration("20200728005145_AddDisplayPreferences")] + partial class AddDisplayPreferences + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("jellyfin") + .HasAnnotation("ProductVersion", "3.1.6"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasColumnType("TEXT") + .HasMaxLength(256); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(512); + + b.Property<string>("Overview") + .HasColumnType("TEXT") + .HasMaxLength(512); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasColumnType("TEXT") + .HasMaxLength(512); + + b.Property<string>("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(256); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(32); + + b.Property<string>("DashboardTheme") + .HasColumnType("TEXT") + .HasMaxLength(32); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasColumnType("TEXT") + .HasMaxLength(32); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(512); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(32); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(64); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Permission_Permissions_Guid"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(65535); + + b.HasKey("Id"); + + b.HasIndex("Preference_Preferences_Guid"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasColumnType("TEXT") + .HasMaxLength(255); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(255); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<string>("EasyPassword") + .HasColumnType("TEXT") + .HasMaxLength(65535); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("Password") + .HasColumnType("TEXT") + .HasMaxLength(65535); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(255); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasColumnType("TEXT") + .HasMaxLength(255); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(255); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("DisplayPreferences") + .HasForeignKey("Jellyfin.Data.Entities.DisplayPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("Permission_Permissions_Guid"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("Preference_Preferences_Guid"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs new file mode 100644 index 000000000..3009f0b61 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs @@ -0,0 +1,132 @@ +#pragma warning disable CS1591 +#pragma warning disable SA1601 + +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Jellyfin.Server.Implementations.Migrations +{ + public partial class AddDisplayPreferences : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DisplayPreferences", + schema: "jellyfin", + columns: table => new + { + Id = table.Column<int>(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column<Guid>(nullable: false), + Client = table.Column<string>(maxLength: 32, nullable: false), + ShowSidebar = table.Column<bool>(nullable: false), + ShowBackdrop = table.Column<bool>(nullable: false), + ScrollDirection = table.Column<int>(nullable: false), + IndexBy = table.Column<int>(nullable: true), + SkipForwardLength = table.Column<int>(nullable: false), + SkipBackwardLength = table.Column<int>(nullable: false), + ChromecastVersion = table.Column<int>(nullable: false), + EnableNextVideoInfoOverlay = table.Column<bool>(nullable: false), + DashboardTheme = table.Column<string>(maxLength: 32, nullable: true), + TvHome = table.Column<string>(maxLength: 32, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DisplayPreferences", x => x.Id); + table.ForeignKey( + name: "FK_DisplayPreferences_Users_UserId", + column: x => x.UserId, + principalSchema: "jellyfin", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ItemDisplayPreferences", + schema: "jellyfin", + columns: table => new + { + Id = table.Column<int>(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column<Guid>(nullable: false), + ItemId = table.Column<Guid>(nullable: false), + Client = table.Column<string>(maxLength: 32, nullable: false), + ViewType = table.Column<int>(nullable: false), + RememberIndexing = table.Column<bool>(nullable: false), + IndexBy = table.Column<int>(nullable: true), + RememberSorting = table.Column<bool>(nullable: false), + SortBy = table.Column<string>(maxLength: 64, nullable: false), + SortOrder = table.Column<int>(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ItemDisplayPreferences", x => x.Id); + table.ForeignKey( + name: "FK_ItemDisplayPreferences_Users_UserId", + column: x => x.UserId, + principalSchema: "jellyfin", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "HomeSection", + schema: "jellyfin", + columns: table => new + { + Id = table.Column<int>(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DisplayPreferencesId = table.Column<int>(nullable: false), + Order = table.Column<int>(nullable: false), + Type = table.Column<int>(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_HomeSection", x => x.Id); + table.ForeignKey( + name: "FK_HomeSection_DisplayPreferences_DisplayPreferencesId", + column: x => x.DisplayPreferencesId, + principalSchema: "jellyfin", + principalTable: "DisplayPreferences", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_DisplayPreferences_UserId", + schema: "jellyfin", + table: "DisplayPreferences", + column: "UserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_HomeSection_DisplayPreferencesId", + schema: "jellyfin", + table: "HomeSection", + column: "DisplayPreferencesId"); + + migrationBuilder.CreateIndex( + name: "IX_ItemDisplayPreferences_UserId", + schema: "jellyfin", + table: "ItemDisplayPreferences", + column: "UserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "HomeSection", + schema: "jellyfin"); + + migrationBuilder.DropTable( + name: "ItemDisplayPreferences", + schema: "jellyfin"); + + migrationBuilder.DropTable( + name: "DisplayPreferences", + schema: "jellyfin"); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index 51fad8224..a6e6a2324 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("jellyfin") - .HasAnnotation("ProductVersion", "3.1.4"); + .HasAnnotation("ProductVersion", "3.1.6"); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => { @@ -88,6 +88,82 @@ namespace Jellyfin.Server.Implementations.Migrations b.ToTable("ActivityLogs"); }); + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(32); + + b.Property<string>("DashboardTheme") + .HasColumnType("TEXT") + .HasMaxLength(32); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasColumnType("TEXT") + .HasMaxLength(32); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => { b.Property<int>("Id") @@ -113,6 +189,50 @@ namespace Jellyfin.Server.Implementations.Migrations b.ToTable("ImageInfos"); }); + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(32); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(64); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => { b.Property<int>("Id") @@ -282,6 +402,24 @@ namespace Jellyfin.Server.Implementations.Migrations .IsRequired(); }); + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("DisplayPreferences") + .HasForeignKey("Jellyfin.Data.Entities.DisplayPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => { b.HasOne("Jellyfin.Data.Entities.User", null) @@ -289,6 +427,15 @@ namespace Jellyfin.Server.Implementations.Migrations .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId"); }); + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => { b.HasOne("Jellyfin.Data.Entities.User", null) diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 007c29643..6cb13cd23 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Security.Cryptography; +using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Common; @@ -11,7 +12,6 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Users; namespace Jellyfin.Server.Implementations.Users @@ -23,7 +23,6 @@ namespace Jellyfin.Server.Implementations.Users { private const string BaseResetFileName = "passwordreset"; - private readonly IJsonSerializer _jsonSerializer; private readonly IApplicationHost _appHost; private readonly string _passwordResetFileBase; @@ -33,16 +32,11 @@ namespace Jellyfin.Server.Implementations.Users /// Initializes a new instance of the <see cref="DefaultPasswordResetProvider"/> class. /// </summary> /// <param name="configurationManager">The configuration manager.</param> - /// <param name="jsonSerializer">The JSON serializer.</param> /// <param name="appHost">The application host.</param> - public DefaultPasswordResetProvider( - IServerConfigurationManager configurationManager, - IJsonSerializer jsonSerializer, - IApplicationHost appHost) + public DefaultPasswordResetProvider(IServerConfigurationManager configurationManager, IApplicationHost appHost) { _passwordResetFileBaseDir = configurationManager.ApplicationPaths.ProgramDataPath; _passwordResetFileBase = Path.Combine(_passwordResetFileBaseDir, BaseResetFileName); - _jsonSerializer = jsonSerializer; _appHost = appHost; // TODO: Remove the circular dependency on UserManager } @@ -63,7 +57,7 @@ namespace Jellyfin.Server.Implementations.Users SerializablePasswordReset spr; await using (var str = File.OpenRead(resetFile)) { - spr = await _jsonSerializer.DeserializeFromStreamAsync<SerializablePasswordReset>(str).ConfigureAwait(false); + spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false); } if (spr.ExpirationDate < DateTime.UtcNow) @@ -119,7 +113,7 @@ namespace Jellyfin.Server.Implementations.Users await using (FileStream fileStream = File.OpenWrite(filePath)) { - _jsonSerializer.SerializeToStream(spr, fileStream); + await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); await fileStream.FlushAsync().ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs new file mode 100644 index 000000000..7c5c5a3ec --- /dev/null +++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs @@ -0,0 +1,88 @@ +#pragma warning disable CA1307 + +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Users +{ + /// <summary> + /// Manages the storage and retrieval of display preferences through Entity Framework. + /// </summary> + public class DisplayPreferencesManager : IDisplayPreferencesManager + { + private readonly JellyfinDbProvider _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class. + /// </summary> + /// <param name="dbProvider">The Jellyfin db provider.</param> + public DisplayPreferencesManager(JellyfinDbProvider dbProvider) + { + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public DisplayPreferences GetDisplayPreferences(Guid userId, string client) + { + using var dbContext = _dbProvider.CreateContext(); + var prefs = dbContext.DisplayPreferences + .Include(pref => pref.HomeSections) + .FirstOrDefault(pref => + pref.UserId == userId && string.Equals(pref.Client, client)); + + if (prefs == null) + { + prefs = new DisplayPreferences(userId, client); + dbContext.DisplayPreferences.Add(prefs); + } + + return prefs; + } + + /// <inheritdoc /> + public ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client) + { + using var dbContext = _dbProvider.CreateContext(); + var prefs = dbContext.ItemDisplayPreferences + .FirstOrDefault(pref => pref.UserId == userId && pref.ItemId == itemId && string.Equals(pref.Client, client)); + + if (prefs == null) + { + prefs = new ItemDisplayPreferences(userId, Guid.Empty, client); + dbContext.ItemDisplayPreferences.Add(prefs); + } + + return prefs; + } + + /// <inheritdoc /> + public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client) + { + using var dbContext = _dbProvider.CreateContext(); + + return dbContext.ItemDisplayPreferences + .Where(prefs => prefs.UserId == userId && prefs.ItemId != Guid.Empty && string.Equals(prefs.Client, client)) + .ToList(); + } + + /// <inheritdoc /> + public void SaveChanges(DisplayPreferences preferences) + { + using var dbContext = _dbProvider.CreateContext(); + dbContext.Update(preferences); + dbContext.SaveChanges(); + } + + /// <inheritdoc /> + public void SaveChanges(ItemDisplayPreferences preferences) + { + using var dbContext = _dbProvider.CreateContext(); + dbContext.Update(preferences); + dbContext.SaveChanges(); + } + } +} diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index eaa6a0a81..11402ee05 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -600,18 +600,13 @@ namespace Jellyfin.Server.Implementations.Users } var defaultName = Environment.UserName; - if (string.IsNullOrWhiteSpace(defaultName)) + if (string.IsNullOrWhiteSpace(defaultName) || !IsValidUsername(defaultName)) { defaultName = "MyJellyfinUser"; } _logger.LogWarning("No users, creating one with username {UserName}", defaultName); - if (!IsValidUsername(defaultName)) - { - throw new ArgumentException("Provided username is not valid!", defaultName); - } - var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); newUser.SetPermission(PermissionKind.IsAdministrator, true); newUser.SetPermission(PermissionKind.EnableContentDeletion, true); diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 71b0fd8f3..29a59e1c8 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -64,16 +64,18 @@ namespace Jellyfin.Server Logger.LogWarning($"Skia not available. Will fallback to {nameof(NullImageEncoder)}."); } - // TODO: Set up scoping and use AddDbContextPool - serviceCollection.AddDbContext<JellyfinDb>( - options => options - .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"), - ServiceLifetime.Transient); + // TODO: Set up scoping and use AddDbContextPool, + // can't register as Transient since tracking transient in GC is funky + // serviceCollection.AddDbContext<JellyfinDb>( + // options => options + // .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"), + // ServiceLifetime.Transient); serviceCollection.AddSingleton<JellyfinDbProvider>(); serviceCollection.AddSingleton<IActivityManager, ActivityManager>(); serviceCollection.AddSingleton<IUserManager, UserManager>(); + serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>(); base.RegisterServices(serviceCollection); } diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index db06eb455..745567703 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -1,3 +1,4 @@ +using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; namespace Jellyfin.Server.Extensions @@ -11,17 +12,39 @@ namespace Jellyfin.Server.Extensions /// Adds swagger and swagger UI to the application pipeline. /// </summary> /// <param name="applicationBuilder">The application builder.</param> + /// <param name="serverConfigurationManager">The server configuration.</param> /// <returns>The updated application builder.</returns> - public static IApplicationBuilder UseJellyfinApiSwagger(this IApplicationBuilder applicationBuilder) + public static IApplicationBuilder UseJellyfinApiSwagger( + this IApplicationBuilder applicationBuilder, + IServerConfigurationManager serverConfigurationManager) { - applicationBuilder.UseSwagger(); - // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), // specifying the Swagger JSON endpoint. - return applicationBuilder.UseSwaggerUI(c => + + var baseUrl = serverConfigurationManager.Configuration.BaseUrl.Trim('/'); + if (!string.IsNullOrEmpty(baseUrl)) { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "Jellyfin API V1"); - }); + baseUrl += '/'; + } + + return applicationBuilder + .UseSwagger(c => + { + // Custom path requires {documentName}, SwaggerDoc documentName is 'api-docs' + c.RouteTemplate = $"/{baseUrl}{{documentName}}/openapi.json"; + }) + .UseSwaggerUI(c => + { + c.DocumentTitle = "Jellyfin API"; + c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API"); + c.RoutePrefix = $"{baseUrl}api-docs/swagger"; + }) + .UseReDoc(c => + { + c.DocumentTitle = "Jellyfin API"; + c.SpecUrl($"/{baseUrl}api-docs/openapi.json"); + c.RoutePrefix = $"{baseUrl}api-docs/redoc"; + }); } } } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 71ef9a69a..83d8fac5b 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -1,13 +1,32 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; using Jellyfin.Api; using Jellyfin.Api.Auth; +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; +using Jellyfin.Api.Auth.DownloadPolicy; +using Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy; +using Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy; using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; +using Jellyfin.Api.Auth.IgnoreParentalControlPolicy; +using Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy; +using Jellyfin.Api.Auth.LocalAccessPolicy; using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; +using Jellyfin.Server.Formatters; +using Jellyfin.Server.Models; +using MediaBrowser.Common.Json; +using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Extensions { @@ -23,16 +42,37 @@ namespace Jellyfin.Server.Extensions /// <returns>The updated service collection.</returns> public static IServiceCollection AddJellyfinApiAuthorization(this IServiceCollection serviceCollection) { + serviceCollection.AddSingleton<IAuthorizationHandler, DefaultAuthorizationHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, DownloadHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeSetupOrDefaultHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeSetupOrElevatedHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, IgnoreParentalControlHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeOrIgnoreParentalControlSetupHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessOrRequiresElevationHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, RequiresElevationHandler>(); return serviceCollection.AddAuthorizationCore(options => { options.AddPolicy( - Policies.RequiresElevation, + Policies.DefaultAuthorization, policy => { policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new RequiresElevationRequirement()); + policy.AddRequirements(new DefaultAuthorizationRequirement()); + }); + options.AddPolicy( + Policies.Download, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new DownloadRequirement()); + }); + options.AddPolicy( + Policies.FirstTimeSetupOrDefault, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new FirstTimeSetupOrDefaultRequirement()); }); options.AddPolicy( Policies.FirstTimeSetupOrElevated, @@ -41,6 +81,41 @@ namespace Jellyfin.Server.Extensions policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); policy.AddRequirements(new FirstTimeSetupOrElevatedRequirement()); }); + options.AddPolicy( + Policies.IgnoreParentalControl, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new IgnoreParentalControlRequirement()); + }); + options.AddPolicy( + Policies.FirstTimeSetupOrIgnoreParentalControl, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new FirstTimeOrIgnoreParentalControlSetupRequirement()); + }); + options.AddPolicy( + Policies.LocalAccessOnly, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new LocalAccessRequirement()); + }); + options.AddPolicy( + Policies.LocalAccessOrRequiresElevation, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new LocalAccessOrRequiresElevationRequirement()); + }); + options.AddPolicy( + Policies.RequiresElevation, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new RequiresElevationRequirement()); + }); }); } @@ -63,9 +138,22 @@ namespace Jellyfin.Server.Extensions /// <returns>The MVC builder.</returns> public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl) { - return serviceCollection.AddMvc(opts => + return serviceCollection + .AddCors(options => + { + options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy); + }) + .Configure<ForwardedHeadersOptions>(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + }) + .AddMvc(opts => { opts.UseGeneralRoutePrefix(baseUrl); + opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter()); + opts.OutputFormatters.Insert(0, new PascalCaseJsonProfileFormatter()); + + opts.OutputFormatters.Add(new CssOutputFormatter()); }) // Clear app parts to avoid other assemblies being picked up @@ -73,8 +161,20 @@ namespace Jellyfin.Server.Extensions .AddApplicationPart(typeof(StartupController).Assembly) .AddJsonOptions(options => { - // Setting the naming policy to null leaves the property names as-is when serializing objects to JSON. - options.JsonSerializerOptions.PropertyNamingPolicy = null; + // Update all properties that are set in JsonDefaults + var jsonOptions = JsonDefaults.GetPascalCaseOptions(); + + // From JsonDefaults + options.JsonSerializerOptions.ReadCommentHandling = jsonOptions.ReadCommentHandling; + options.JsonSerializerOptions.WriteIndented = jsonOptions.WriteIndented; + options.JsonSerializerOptions.Converters.Clear(); + foreach (var converter in jsonOptions.Converters) + { + options.JsonSerializerOptions.Converters.Add(converter); + } + + // From JsonDefaults.PascalCase + options.JsonSerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy; }) .AddControllersAsServices(); } @@ -88,8 +188,101 @@ namespace Jellyfin.Server.Extensions { return serviceCollection.AddSwaggerGen(c => { - c.SwaggerDoc("v1", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Name = "X-Emby-Token", + Description = "API key header parameter" + }); + + var securitySchemeRef = new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = AuthenticationSchemes.CustomAuthentication }, + }; + + // TODO: Apply this with an operation filter instead of globally + // https://github.com/domaindrivendev/Swashbuckle.AspNetCore#add-security-definitions-and-requirements + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { securitySchemeRef, Array.Empty<string>() } + }); + + // Add all xml doc files to swagger generator. + var xmlFiles = Directory.GetFiles( + AppContext.BaseDirectory, + "*.xml", + SearchOption.TopDirectoryOnly); + + foreach (var xmlFile in xmlFiles) + { + c.IncludeXmlComments(xmlFile); + } + + // Order actions by route path, then by http method. + c.OrderActionsBy(description => + $"{description.ActionDescriptor.RouteValues["controller"]}_{description.RelativePath}"); + + // Use method name as operationId + c.CustomOperationIds( + description => + { + description.TryGetMethodInfo(out MethodInfo methodInfo); + // Attribute name, method name, none. + return description?.ActionDescriptor?.AttributeRouteInfo?.Name + ?? methodInfo?.Name + ?? null; + }); + + // TODO - remove when all types are supported in System.Text.Json + c.AddSwaggerTypeMappings(); }); } + + private static void AddSwaggerTypeMappings(this SwaggerGenOptions options) + { + /* + * TODO remove when System.Text.Json supports non-string keys. + * Used in Jellyfin.Api.Controller.GetChannels. + */ + options.MapType<Dictionary<ImageType, string>>(() => + new OpenApiSchema + { + Type = "object", + Properties = typeof(ImageType).GetEnumNames().ToDictionary( + name => name, + name => new OpenApiSchema + { + Type = "string", + Format = "string" + }) + }); + + /* + * Support BlurHash dictionary + */ + options.MapType<Dictionary<ImageType, Dictionary<string, string>>>(() => + new OpenApiSchema + { + Type = "object", + Properties = typeof(ImageType).GetEnumNames().ToDictionary( + name => name, + name => new OpenApiSchema + { + Type = "object", Properties = new Dictionary<string, OpenApiSchema> + { + { + "string", + new OpenApiSchema + { + Type = "string", + Format = "string" + } + } + } + }) + }); + } } } diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs new file mode 100644 index 000000000..9b347ae2c --- /dev/null +++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs @@ -0,0 +1,21 @@ +using MediaBrowser.Common.Json; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Server.Formatters +{ + /// <summary> + /// Camel Case Json Profile Formatter. + /// </summary> + public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter + { + /// <summary> + /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class. + /// </summary> + public CamelCaseJsonProfileFormatter() : base(JsonDefaults.GetCamelCaseOptions()) + { + SupportedMediaTypes.Clear(); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\"")); + } + } +} diff --git a/Jellyfin.Server/Formatters/CssOutputFormatter.cs b/Jellyfin.Server/Formatters/CssOutputFormatter.cs new file mode 100644 index 000000000..b3771b7fe --- /dev/null +++ b/Jellyfin.Server/Formatters/CssOutputFormatter.cs @@ -0,0 +1,36 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Jellyfin.Server.Formatters +{ + /// <summary> + /// Css output formatter. + /// </summary> + public class CssOutputFormatter : TextOutputFormatter + { + /// <summary> + /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class. + /// </summary> + public CssOutputFormatter() + { + SupportedMediaTypes.Add("text/css"); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + /// <summary> + /// Write context object to stream. + /// </summary> + /// <param name="context">Writer context.</param> + /// <param name="selectedEncoding">Unused. Writer encoding.</param> + /// <returns>Write stream task.</returns> + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + return context.HttpContext.Response.WriteAsync(context.Object?.ToString()); + } + } +} diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs new file mode 100644 index 000000000..0024708ba --- /dev/null +++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Common.Json; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Server.Formatters +{ + /// <summary> + /// Pascal Case Json Profile Formatter. + /// </summary> + public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter + { + /// <summary> + /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class. + /// </summary> + public PascalCaseJsonProfileFormatter() : base(JsonDefaults.GetPascalCaseOptions()) + { + SupportedMediaTypes.Clear(); + // Add application/json for default formatter + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json")); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"PascalCase\"")); + } + } +} diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index b1bd38cff..7541707d9 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -45,7 +45,7 @@ <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.6" /> <PackageReference Include="prometheus-net" Version="3.6.0" /> <PackageReference Include="prometheus-net.AspNetCore" Version="3.6.0" /> - <PackageReference Include="Serilog.AspNetCore" Version="3.2.0" /> + <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" /> <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" /> <PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" /> diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs new file mode 100644 index 000000000..63effafc1 --- /dev/null +++ b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +using System.Net.Mime; +using System.Net.Sockets; +using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Net; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// Exception Middleware. + /// </summary> + public class ExceptionMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger<ExceptionMiddleware> _logger; + private readonly IServerConfigurationManager _configuration; + private readonly IWebHostEnvironment _hostEnvironment; + + /// <summary> + /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class. + /// </summary> + /// <param name="next">Next request delegate.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="hostEnvironment">Instance of the <see cref="IWebHostEnvironment"/> interface.</param> + public ExceptionMiddleware( + RequestDelegate next, + ILogger<ExceptionMiddleware> logger, + IServerConfigurationManager serverConfigurationManager, + IWebHostEnvironment hostEnvironment) + { + _next = next; + _logger = logger; + _configuration = serverConfigurationManager; + _hostEnvironment = hostEnvironment; + } + + /// <summary> + /// Invoke request. + /// </summary> + /// <param name="context">Request context.</param> + /// <returns>Task.</returns> + public async Task Invoke(HttpContext context) + { + try + { + await _next(context).ConfigureAwait(false); + } + catch (Exception ex) + { + if (context.Response.HasStarted) + { + _logger.LogWarning("The response has already started, the exception middleware will not be executed."); + throw; + } + + ex = GetActualException(ex); + + bool ignoreStackTrace = + ex is SocketException + || ex is IOException + || ex is OperationCanceledException + || ex is SecurityException + || ex is AuthenticationException + || ex is FileNotFoundException; + + if (ignoreStackTrace) + { + _logger.LogError( + "Error processing request: {ExceptionMessage}. URL {Method} {Url}.", + ex.Message.TrimEnd('.'), + context.Request.Method, + context.Request.Path); + } + else + { + _logger.LogError( + ex, + "Error processing request. URL {Method} {Url}.", + context.Request.Method, + context.Request.Path); + } + + context.Response.StatusCode = GetStatusCode(ex); + context.Response.ContentType = MediaTypeNames.Text.Plain; + + // Don't send exception unless the server is in a Development environment + var errorContent = _hostEnvironment.IsDevelopment() + ? NormalizeExceptionMessage(ex.Message) + : "Error processing request."; + await context.Response.WriteAsync(errorContent).ConfigureAwait(false); + } + } + + private static Exception GetActualException(Exception ex) + { + if (ex is AggregateException agg) + { + var inner = agg.InnerException; + if (inner != null) + { + return GetActualException(inner); + } + + var inners = agg.InnerExceptions; + if (inners.Count > 0) + { + return GetActualException(inners[0]); + } + } + + return ex; + } + + private static int GetStatusCode(Exception ex) + { + switch (ex) + { + case ArgumentException _: return StatusCodes.Status400BadRequest; + case SecurityException _: return StatusCodes.Status401Unauthorized; + case DirectoryNotFoundException _: + case FileNotFoundException _: + case ResourceNotFoundException _: return StatusCodes.Status404NotFound; + case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed; + default: return StatusCodes.Status500InternalServerError; + } + } + + private string NormalizeExceptionMessage(string msg) + { + if (msg == null) + { + return string.Empty; + } + + // Strip any information we don't want to reveal + return msg.Replace( + _configuration.ApplicationPaths.ProgramSystemPath, + string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace( + _configuration.ApplicationPaths.ProgramDataPath, + string.Empty, + StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs b/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs new file mode 100644 index 000000000..3122d92cb --- /dev/null +++ b/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using System.Globalization; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// Response time middleware. + /// </summary> + public class ResponseTimeMiddleware + { + private const string ResponseHeaderResponseTime = "X-Response-Time-ms"; + + private readonly RequestDelegate _next; + private readonly ILogger<ResponseTimeMiddleware> _logger; + + private readonly bool _enableWarning; + private readonly long _warningThreshold; + + /// <summary> + /// Initializes a new instance of the <see cref="ResponseTimeMiddleware"/> class. + /// </summary> + /// <param name="next">Next request delegate.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public ResponseTimeMiddleware( + RequestDelegate next, + ILogger<ResponseTimeMiddleware> logger, + IServerConfigurationManager serverConfigurationManager) + { + _next = next; + _logger = logger; + + _enableWarning = serverConfigurationManager.Configuration.EnableSlowResponseWarning; + _warningThreshold = serverConfigurationManager.Configuration.SlowResponseThresholdMs; + } + + /// <summary> + /// Invoke request. + /// </summary> + /// <param name="context">Request context.</param> + /// <returns>Task.</returns> + public async Task Invoke(HttpContext context) + { + var watch = new Stopwatch(); + watch.Start(); + + context.Response.OnStarting(() => + { + watch.Stop(); + LogWarning(context, watch); + var responseTimeForCompleteRequest = watch.ElapsedMilliseconds; + context.Response.Headers[ResponseHeaderResponseTime] = responseTimeForCompleteRequest.ToString(CultureInfo.InvariantCulture); + return Task.CompletedTask; + }); + + // Call the next delegate/middleware in the pipeline + await this._next(context).ConfigureAwait(false); + } + + private void LogWarning(HttpContext context, Stopwatch watch) + { + if (_enableWarning && watch.ElapsedMilliseconds > _warningThreshold) + { + _logger.LogWarning( + "Slow HTTP Response from {url} to {remoteIp} in {elapsed:g} with Status Code {statusCode}", + context.Request.GetDisplayUrl(), + context.Connection.RemoteIpAddress, + watch.Elapsed, + context.Response.StatusCode); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 98a90500c..844dae61f 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -22,7 +22,8 @@ namespace Jellyfin.Server.Migrations typeof(Routines.RemoveDuplicateExtras), typeof(Routines.AddDefaultPluginRepository), typeof(Routines.MigrateUserDb), - typeof(Routines.ReaddDefaultPluginRepository) + typeof(Routines.ReaddDefaultPluginRepository), + typeof(Routines.MigrateDisplayPreferencesDb) }; /// <summary> diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs new file mode 100644 index 000000000..b15ccf01e --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Server.Implementations; +using MediaBrowser.Controller; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using SQLitePCL.pretty; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// <summary> + /// The migration routine for migrating the display preferences database to EF Core. + /// </summary> + public class MigrateDisplayPreferencesDb : IMigrationRoutine + { + private const string DbFilename = "displaypreferences.db"; + + private readonly ILogger<MigrateDisplayPreferencesDb> _logger; + private readonly IServerApplicationPaths _paths; + private readonly JellyfinDbProvider _provider; + private readonly JsonSerializerOptions _jsonOptions; + + /// <summary> + /// Initializes a new instance of the <see cref="MigrateDisplayPreferencesDb"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="paths">The server application paths.</param> + /// <param name="provider">The database provider.</param> + public MigrateDisplayPreferencesDb(ILogger<MigrateDisplayPreferencesDb> logger, IServerApplicationPaths paths, JellyfinDbProvider provider) + { + _logger = logger; + _paths = paths; + _provider = provider; + _jsonOptions = new JsonSerializerOptions(); + _jsonOptions.Converters.Add(new JsonStringEnumConverter()); + } + + /// <inheritdoc /> + public Guid Id => Guid.Parse("06387815-C3CC-421F-A888-FB5F9992BEA8"); + + /// <inheritdoc /> + public string Name => "MigrateDisplayPreferencesDatabase"; + + /// <inheritdoc /> + public bool PerformOnNewInstall => false; + + /// <inheritdoc /> + public void Perform() + { + HomeSectionType[] defaults = + { + HomeSectionType.SmallLibraryTiles, + HomeSectionType.Resume, + HomeSectionType.ResumeAudio, + HomeSectionType.LiveTv, + HomeSectionType.NextUp, + HomeSectionType.LatestMedia, + HomeSectionType.None, + }; + + var chromecastDict = new Dictionary<string, ChromecastVersion>(StringComparer.OrdinalIgnoreCase) + { + { "stable", ChromecastVersion.Stable }, + { "nightly", ChromecastVersion.Unstable }, + { "unstable", ChromecastVersion.Unstable } + }; + + var dbFilePath = Path.Combine(_paths.DataPath, DbFilename); + using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null)) + { + using var dbContext = _provider.CreateContext(); + + var results = connection.Query("SELECT * FROM userdisplaypreferences"); + foreach (var result in results) + { + var dto = JsonSerializer.Deserialize<DisplayPreferencesDto>(result[3].ToString(), _jsonOptions); + var chromecastVersion = dto.CustomPrefs.TryGetValue("chromecastVersion", out var version) + ? chromecastDict[version] + : ChromecastVersion.Stable; + + var displayPreferences = new DisplayPreferences(new Guid(result[1].ToBlob()), result[2].ToString()) + { + IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null, + ShowBackdrop = dto.ShowBackdrop, + ShowSidebar = dto.ShowSidebar, + ScrollDirection = dto.ScrollDirection, + ChromecastVersion = chromecastVersion, + SkipForwardLength = dto.CustomPrefs.TryGetValue("skipForwardLength", out var length) + ? int.Parse(length, CultureInfo.InvariantCulture) + : 30000, + SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length) + ? int.Parse(length, CultureInfo.InvariantCulture) + : 10000, + EnableNextVideoInfoOverlay = dto.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enabled) + ? bool.Parse(enabled) + : true, + DashboardTheme = dto.CustomPrefs.TryGetValue("dashboardtheme", out var theme) ? theme : string.Empty, + TvHome = dto.CustomPrefs.TryGetValue("tvhome", out var home) ? home : string.Empty + }; + + for (int i = 0; i < 7; i++) + { + dto.CustomPrefs.TryGetValue("homesection" + i, out var homeSection); + + displayPreferences.HomeSections.Add(new HomeSection + { + Order = i, + Type = Enum.TryParse<HomeSectionType>(homeSection, true, out var type) ? type : defaults[i] + }); + } + + var defaultLibraryPrefs = new ItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client) + { + SortBy = dto.SortBy ?? "SortName", + SortOrder = dto.SortOrder, + RememberIndexing = dto.RememberIndexing, + RememberSorting = dto.RememberSorting, + }; + + dbContext.Add(defaultLibraryPrefs); + + foreach (var key in dto.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.Ordinal))) + { + if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var itemId)) + { + continue; + } + + var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client) + { + SortBy = dto.SortBy ?? "SortName", + SortOrder = dto.SortOrder, + RememberIndexing = dto.RememberIndexing, + RememberSorting = dto.RememberSorting, + }; + + if (Enum.TryParse<ViewType>(dto.ViewType, true, out var viewType)) + { + libraryDisplayPreferences.ViewType = viewType; + } + + dbContext.ItemDisplayPreferences.Add(libraryDisplayPreferences); + } + + dbContext.Add(displayPreferences); + } + + dbContext.SaveChanges(); + } + + try + { + File.Move(dbFilePath, dbFilePath + ".old"); + + var journalPath = dbFilePath + "-journal"; + if (File.Exists(journalPath)) + { + File.Move(journalPath, dbFilePath + ".old-journal"); + } + } + catch (IOException e) + { + _logger.LogError(e, "Error renaming legacy display preferences database to 'displaypreferences.db.old'"); + } + } + } +} diff --git a/Jellyfin.Server/Models/ServerCorsPolicy.cs b/Jellyfin.Server/Models/ServerCorsPolicy.cs new file mode 100644 index 000000000..ae010c042 --- /dev/null +++ b/Jellyfin.Server/Models/ServerCorsPolicy.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Cors.Infrastructure; + +namespace Jellyfin.Server.Models +{ + /// <summary> + /// Server Cors Policy. + /// </summary> + public static class ServerCorsPolicy + { + /// <summary> + /// Default policy name. + /// </summary> + public const string DefaultPolicyName = "DefaultCorsPolicy"; + + /// <summary> + /// Default Policy. Allow Everything. + /// </summary> + public static readonly CorsPolicy DefaultPolicy = new CorsPolicy + { + // Allow any origin + Origins = { "*" }, + + // Allow any method + Methods = { "*" }, + + // Allow any header + Headers = { "*" } + }; + } +}
\ No newline at end of file diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 444a91c02..9fd706d36 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -14,9 +14,9 @@ using Emby.Server.Implementations; using Emby.Server.Implementations.HttpServer; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Networking; +using Jellyfin.Api.Controllers; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Extensions; -using MediaBrowser.WebDashboard.Api; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; @@ -167,7 +167,7 @@ namespace Jellyfin.Server // If hosting the web client, validate the client content path if (startupConfig.HostWebClient()) { - string webContentPath = DashboardService.GetDashboardUIPath(startupConfig, appHost.ServerConfigurationManager); + string? webContentPath = DashboardController.GetWebClientUiPath(startupConfig, appHost.ServerConfigurationManager); if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0) { throw new InvalidOperationException( @@ -580,7 +580,7 @@ namespace Jellyfin.Server var inMemoryDefaultConfig = ConfigurationOptions.DefaultConfiguration; if (startupConfig != null && !startupConfig.HostWebClient()) { - inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "swagger/index.html"; + inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "api-docs/swagger"; } return config diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 5f9a5c161..108d8f881 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,4 +1,7 @@ +using System.Net.Http; using Jellyfin.Server.Extensions; +using Jellyfin.Server.Middleware; +using Jellyfin.Server.Models; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; @@ -41,6 +44,7 @@ namespace Jellyfin.Server services.AddCustomAuthentication(); services.AddJellyfinApiAuthorization(); + services.AddHttpClient(); } /// <summary> @@ -59,15 +63,20 @@ namespace Jellyfin.Server app.UseDeveloperExceptionPage(); } + app.UseMiddleware<ExceptionMiddleware>(); + + app.UseMiddleware<ResponseTimeMiddleware>(); + app.UseWebSockets(); app.UseResponseCompression(); // TODO app.UseMiddleware<WebSocketMiddleware>(); - // TODO use when old API is removed: app.UseAuthentication(); - app.UseJellyfinApiSwagger(); + app.UseAuthentication(); + app.UseJellyfinApiSwagger(_serverConfigurationManager); app.UseRouting(); + app.UseCors(ServerCorsPolicy.DefaultPolicyName); app.UseAuthorization(); if (_serverConfigurationManager.Configuration.EnableMetrics) { diff --git a/MediaBrowser.Api/ApiEntryPoint.cs b/MediaBrowser.Api/ApiEntryPoint.cs deleted file mode 100644 index b041effb2..000000000 --- a/MediaBrowser.Api/ApiEntryPoint.cs +++ /dev/null @@ -1,678 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Api.Playback; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Session; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class ServerEntryPoint. - /// </summary> - public class ApiEntryPoint : IServerEntryPoint - { - /// <summary> - /// The instance. - /// </summary> - public static ApiEntryPoint Instance; - - /// <summary> - /// The logger. - /// </summary> - private ILogger<ApiEntryPoint> _logger; - - /// <summary> - /// The configuration manager. - /// </summary> - private IServerConfigurationManager _serverConfigurationManager; - - private readonly ISessionManager _sessionManager; - private readonly IFileSystem _fileSystem; - private readonly IMediaSourceManager _mediaSourceManager; - - /// <summary> - /// The active transcoding jobs. - /// </summary> - private readonly List<TranscodingJob> _activeTranscodingJobs = new List<TranscodingJob>(); - - private readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = - new Dictionary<string, SemaphoreSlim>(); - - private bool _disposed = false; - - /// <summary> - /// Initializes a new instance of the <see cref="ApiEntryPoint" /> class. - /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="sessionManager">The session manager.</param> - /// <param name="config">The configuration.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="mediaSourceManager">The media source manager.</param> - public ApiEntryPoint( - ILogger<ApiEntryPoint> logger, - ISessionManager sessionManager, - IServerConfigurationManager config, - IFileSystem fileSystem, - IMediaSourceManager mediaSourceManager) - { - _logger = logger; - _sessionManager = sessionManager; - _serverConfigurationManager = config; - _fileSystem = fileSystem; - _mediaSourceManager = mediaSourceManager; - - _sessionManager.PlaybackProgress += OnPlaybackProgress; - _sessionManager.PlaybackStart += OnPlaybackStart; - - Instance = this; - } - - public static string[] Split(string value, char separator, bool removeEmpty) - { - if (string.IsNullOrWhiteSpace(value)) - { - return Array.Empty<string>(); - } - - return removeEmpty - ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries) - : value.Split(separator); - } - - public SemaphoreSlim GetTranscodingLock(string outputPath) - { - lock (_transcodingLocks) - { - if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim result)) - { - result = new SemaphoreSlim(1, 1); - _transcodingLocks[outputPath] = result; - } - - return result; - } - } - - private void OnPlaybackStart(object sender, PlaybackProgressEventArgs e) - { - if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) - { - PingTranscodingJob(e.PlaySessionId, e.IsPaused); - } - } - - private void OnPlaybackProgress(object sender, PlaybackProgressEventArgs e) - { - if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) - { - PingTranscodingJob(e.PlaySessionId, e.IsPaused); - } - } - - /// <summary> - /// Runs this instance. - /// </summary> - public Task RunAsync() - { - try - { - DeleteEncodedMediaCache(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting encoded media cache"); - } - - return Task.CompletedTask; - } - - /// <summary> - /// Deletes the encoded media cache. - /// </summary> - private void DeleteEncodedMediaCache() - { - var path = _serverConfigurationManager.GetTranscodePath(); - if (!Directory.Exists(path)) - { - return; - } - - foreach (var file in _fileSystem.GetFilePaths(path, true)) - { - _fileSystem.DeleteFile(file); - } - } - - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) - { - if (_disposed) - { - return; - } - - if (dispose) - { - // TODO: dispose - } - - var jobs = _activeTranscodingJobs.ToList(); - var jobCount = jobs.Count; - - IEnumerable<Task> GetKillJobs() - { - foreach (var job in jobs) - { - yield return KillTranscodingJob(job, false, path => true); - } - } - - // Wait for all processes to be killed - if (jobCount > 0) - { - Task.WaitAll(GetKillJobs().ToArray()); - } - - _activeTranscodingJobs.Clear(); - _transcodingLocks.Clear(); - - _sessionManager.PlaybackProgress -= OnPlaybackProgress; - _sessionManager.PlaybackStart -= OnPlaybackStart; - - _disposed = true; - } - - - /// <summary> - /// Called when [transcode beginning]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="playSessionId">The play session identifier.</param> - /// <param name="liveStreamId">The live stream identifier.</param> - /// <param name="transcodingJobId">The transcoding job identifier.</param> - /// <param name="type">The type.</param> - /// <param name="process">The process.</param> - /// <param name="deviceId">The device id.</param> - /// <param name="state">The state.</param> - /// <param name="cancellationTokenSource">The cancellation token source.</param> - /// <returns>TranscodingJob.</returns> - public TranscodingJob OnTranscodeBeginning( - string path, - string playSessionId, - string liveStreamId, - string transcodingJobId, - TranscodingJobType type, - Process process, - string deviceId, - StreamState state, - CancellationTokenSource cancellationTokenSource) - { - lock (_activeTranscodingJobs) - { - var job = new TranscodingJob(_logger) - { - Type = type, - Path = path, - Process = process, - ActiveRequestCount = 1, - DeviceId = deviceId, - CancellationTokenSource = cancellationTokenSource, - Id = transcodingJobId, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId, - MediaSource = state.MediaSource - }; - - _activeTranscodingJobs.Add(job); - - ReportTranscodingProgress(job, state, null, null, null, null, null); - - return job; - } - } - - public void ReportTranscodingProgress(TranscodingJob job, StreamState state, TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) - { - var ticks = transcodingPosition?.Ticks; - - if (job != null) - { - job.Framerate = framerate; - job.CompletionPercentage = percentComplete; - job.TranscodingPositionTicks = ticks; - job.BytesTranscoded = bytesTranscoded; - job.BitRate = bitRate; - } - - var deviceId = state.Request.DeviceId; - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - var audioCodec = state.ActualOutputAudioCodec; - var videoCodec = state.ActualOutputVideoCodec; - - _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo - { - Bitrate = bitRate ?? state.TotalOutputBitrate, - AudioCodec = audioCodec, - VideoCodec = videoCodec, - Container = state.OutputContainer, - Framerate = framerate, - CompletionPercentage = percentComplete, - Width = state.OutputWidth, - Height = state.OutputHeight, - AudioChannels = state.OutputAudioChannels, - IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), - IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), - TranscodeReasons = state.TranscodeReasons - }); - } - } - - /// <summary> - /// <summary> - /// The progressive. - /// </summary> - /// Called when [transcode failed to start]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="type">The type.</param> - /// <param name="state">The state.</param> - public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) - { - lock (_activeTranscodingJobs) - { - var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - - if (job != null) - { - _activeTranscodingJobs.Remove(job); - } - } - - lock (_transcodingLocks) - { - _transcodingLocks.Remove(path); - } - - if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) - { - _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); - } - } - - /// <summary> - /// Determines whether [has active transcoding job] [the specified path]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="type">The type.</param> - /// <returns><c>true</c> if [has active transcoding job] [the specified path]; otherwise, <c>false</c>.</returns> - public bool HasActiveTranscodingJob(string path, TranscodingJobType type) - { - return GetTranscodingJob(path, type) != null; - } - - public TranscodingJob GetTranscodingJob(string path, TranscodingJobType type) - { - lock (_activeTranscodingJobs) - { - return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - } - } - - public TranscodingJob GetTranscodingJob(string playSessionId) - { - lock (_activeTranscodingJobs) - { - return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); - } - } - - /// <summary> - /// Called when [transcode begin request]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="type">The type.</param> - public TranscodingJob OnTranscodeBeginRequest(string path, TranscodingJobType type) - { - lock (_activeTranscodingJobs) - { - var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - - if (job == null) - { - return null; - } - - OnTranscodeBeginRequest(job); - - return job; - } - } - - public void OnTranscodeBeginRequest(TranscodingJob job) - { - job.ActiveRequestCount++; - - if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) - { - job.StopKillTimer(); - } - } - - public void OnTranscodeEndRequest(TranscodingJob job) - { - job.ActiveRequestCount--; - _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={0}", job.ActiveRequestCount); - if (job.ActiveRequestCount <= 0) - { - PingTimer(job, false); - } - } - - internal void PingTranscodingJob(string playSessionId, bool? isUserPaused) - { - if (string.IsNullOrEmpty(playSessionId)) - { - throw new ArgumentNullException(nameof(playSessionId)); - } - - _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); - - List<TranscodingJob> jobs; - - lock (_activeTranscodingJobs) - { - // This is really only needed for HLS. - // Progressive streams can stop on their own reliably - jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - foreach (var job in jobs) - { - if (isUserPaused.HasValue) - { - _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); - job.IsUserPaused = isUserPaused.Value; - } - - PingTimer(job, true); - } - } - - private void PingTimer(TranscodingJob job, bool isProgressCheckIn) - { - if (job.HasExited) - { - job.StopKillTimer(); - return; - } - - var timerDuration = 10000; - - if (job.Type != TranscodingJobType.Progressive) - { - timerDuration = 60000; - } - - job.PingTimeout = timerDuration; - job.LastPingDate = DateTime.UtcNow; - - // Don't start the timer for playback checkins with progressive streaming - if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn) - { - job.StartKillTimer(OnTranscodeKillTimerStopped); - } - else - { - job.ChangeKillTimerIfStarted(); - } - } - - /// <summary> - /// Called when [transcode kill timer stopped]. - /// </summary> - /// <param name="state">The state.</param> - private async void OnTranscodeKillTimerStopped(object state) - { - var job = (TranscodingJob)state; - - if (!job.HasExited && job.Type != TranscodingJobType.Progressive) - { - var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; - - if (timeSinceLastPing < job.PingTimeout) - { - job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout); - return; - } - } - - _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); - - await KillTranscodingJob(job, true, path => true); - } - - /// <summary> - /// Kills the single transcoding job. - /// </summary> - /// <param name="deviceId">The device id.</param> - /// <param name="playSessionId">The play session identifier.</param> - /// <param name="deleteFiles">The delete files.</param> - /// <returns>Task.</returns> - internal Task KillTranscodingJobs(string deviceId, string playSessionId, Func<string, bool> deleteFiles) - { - return KillTranscodingJobs(j => string.IsNullOrWhiteSpace(playSessionId) - ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) - : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), deleteFiles); - } - - /// <summary> - /// Kills the transcoding jobs. - /// </summary> - /// <param name="killJob">The kill job.</param> - /// <param name="deleteFiles">The delete files.</param> - /// <returns>Task.</returns> - private Task KillTranscodingJobs(Func<TranscodingJob, bool> killJob, Func<string, bool> deleteFiles) - { - var jobs = new List<TranscodingJob>(); - - lock (_activeTranscodingJobs) - { - // This is really only needed for HLS. - // Progressive streams can stop on their own reliably - jobs.AddRange(_activeTranscodingJobs.Where(killJob)); - } - - if (jobs.Count == 0) - { - return Task.CompletedTask; - } - - IEnumerable<Task> GetKillJobs() - { - foreach (var job in jobs) - { - yield return KillTranscodingJob(job, false, deleteFiles); - } - } - - return Task.WhenAll(GetKillJobs()); - } - - /// <summary> - /// Kills the transcoding job. - /// </summary> - /// <param name="job">The job.</param> - /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param> - /// <param name="delete">The delete.</param> - private async Task KillTranscodingJob(TranscodingJob job, bool closeLiveStream, Func<string, bool> delete) - { - job.DisposeKillTimer(); - - _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); - - lock (_activeTranscodingJobs) - { - _activeTranscodingJobs.Remove(job); - - if (!job.CancellationTokenSource.IsCancellationRequested) - { - job.CancellationTokenSource.Cancel(); - } - } - - lock (_transcodingLocks) - { - _transcodingLocks.Remove(job.Path); - } - - lock (job.ProcessLock) - { - job.TranscodingThrottler?.Stop().GetAwaiter().GetResult(); - - var process = job.Process; - - var hasExited = job.HasExited; - - if (!hasExited) - { - try - { - _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path); - - process.StandardInput.WriteLine("q"); - - // Need to wait because killing is asynchronous - if (!process.WaitForExit(5000)) - { - _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path); - process.Kill(); - } - } - catch (InvalidOperationException) - { - } - } - } - - if (delete(job.Path)) - { - await DeletePartialStreamFiles(job.Path, job.Type, 0, 1500).ConfigureAwait(false); - } - - if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) - { - try - { - await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); - } - } - } - - private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) - { - if (retryCount >= 10) - { - return; - } - - _logger.LogInformation("Deleting partial stream file(s) {Path}", path); - - await Task.Delay(delayMs).ConfigureAwait(false); - - try - { - if (jobType == TranscodingJobType.Progressive) - { - DeleteProgressivePartialStreamFiles(path); - } - else - { - DeleteHlsPartialStreamFiles(path); - } - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - - await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - } - } - - /// <summary> - /// Deletes the progressive partial stream files. - /// </summary> - /// <param name="outputFilePath">The output file path.</param> - private void DeleteProgressivePartialStreamFiles(string outputFilePath) - { - if (File.Exists(outputFilePath)) - { - _fileSystem.DeleteFile(outputFilePath); - } - } - - /// <summary> - /// Deletes the HLS partial stream files. - /// </summary> - /// <param name="outputFilePath">The output file path.</param> - private void DeleteHlsPartialStreamFiles(string outputFilePath) - { - var directory = Path.GetDirectoryName(outputFilePath); - var name = Path.GetFileNameWithoutExtension(outputFilePath); - - var filesToDelete = _fileSystem.GetFilePaths(directory) - .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1); - - List<Exception> exs = null; - foreach (var file in filesToDelete) - { - try - { - _logger.LogDebug("Deleting HLS file {0}", file); - _fileSystem.DeleteFile(file); - } - catch (IOException ex) - { - (exs ??= new List<Exception>(4)).Add(ex); - _logger.LogError(ex, "Error deleting HLS file {Path}", file); - } - } - - if (exs != null) - { - throw new AggregateException("Error deleting HLS files", exs); - } - } - } -} diff --git a/MediaBrowser.Api/Attachments/AttachmentService.cs b/MediaBrowser.Api/Attachments/AttachmentService.cs deleted file mode 100644 index 1632ca1b0..000000000 --- a/MediaBrowser.Api/Attachments/AttachmentService.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Attachments -{ - [Route("/Videos/{Id}/{MediaSourceId}/Attachments/{Index}", "GET", Summary = "Gets specified attachment.")] - public class GetAttachment - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - - [ApiMember(Name = "MediaSourceId", Description = "MediaSourceId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string MediaSourceId { get; set; } - - [ApiMember(Name = "Index", Description = "The attachment stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")] - public int Index { get; set; } - } - - public class AttachmentService : BaseApiService - { - private readonly ILibraryManager _libraryManager; - private readonly IAttachmentExtractor _attachmentExtractor; - - public AttachmentService( - ILogger<AttachmentService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ILibraryManager libraryManager, - IAttachmentExtractor attachmentExtractor) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _libraryManager = libraryManager; - _attachmentExtractor = attachmentExtractor; - } - - public async Task<object> Get(GetAttachment request) - { - var (attachment, attachmentStream) = await GetAttachment(request).ConfigureAwait(false); - var mime = string.IsNullOrWhiteSpace(attachment.MimeType) ? "application/octet-stream" : attachment.MimeType; - - return ResultFactory.GetResult(Request, attachmentStream, mime); - } - - private Task<(MediaAttachment, Stream)> GetAttachment(GetAttachment request) - { - var item = _libraryManager.GetItemById(request.Id); - - return _attachmentExtractor.GetAttachment(item, - request.MediaSourceId, - request.Index, - CancellationToken.None); - } - } -} diff --git a/MediaBrowser.Api/BaseApiService.cs b/MediaBrowser.Api/BaseApiService.cs deleted file mode 100644 index 63a31a745..000000000 --- a/MediaBrowser.Api/BaseApiService.cs +++ /dev/null @@ -1,416 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class BaseApiService. - /// </summary> - public abstract class BaseApiService : IService, IRequiresRequest - { - public BaseApiService( - ILogger<BaseApiService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory) - { - Logger = logger; - ServerConfigurationManager = serverConfigurationManager; - ResultFactory = httpResultFactory; - } - - /// <summary> - /// Gets the logger. - /// </summary> - /// <value>The logger.</value> - protected ILogger<BaseApiService> Logger { get; } - - /// <summary> - /// Gets or sets the server configuration manager. - /// </summary> - /// <value>The server configuration manager.</value> - protected IServerConfigurationManager ServerConfigurationManager { get; } - - /// <summary> - /// Gets the HTTP result factory. - /// </summary> - /// <value>The HTTP result factory.</value> - protected IHttpResultFactory ResultFactory { get; } - - /// <summary> - /// Gets or sets the request context. - /// </summary> - /// <value>The request context.</value> - public IRequest Request { get; set; } - - public string GetHeader(string name) => Request.Headers[name]; - - public static string[] SplitValue(string value, char delim) - { - return value == null - ? Array.Empty<string>() - : value.Split(new[] { delim }, StringSplitOptions.RemoveEmptyEntries); - } - - public static Guid[] GetGuids(string value) - { - if (value == null) - { - return Array.Empty<Guid>(); - } - - return value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(i => new Guid(i)) - .ToArray(); - } - - /// <summary> - /// To the optimized result. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="result">The result.</param> - /// <returns>System.Object.</returns> - protected object ToOptimizedResult<T>(T result) - where T : class - { - return ResultFactory.GetResult(Request, result); - } - - protected void AssertCanUpdateUser(IAuthorizationContext authContext, IUserManager userManager, Guid userId, bool restrictUserPreferences) - { - var auth = authContext.GetAuthorizationInfo(Request); - - var authenticatedUser = auth.User; - - // If they're going to update the record of another user, they must be an administrator - if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator)) - || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess)) - { - throw new SecurityException("Unauthorized access."); - } - } - - /// <summary> - /// Gets the session. - /// </summary> - /// <returns>SessionInfo.</returns> - protected SessionInfo GetSession(ISessionContext sessionContext) - { - var session = sessionContext.GetSession(Request); - - if (session == null) - { - throw new ArgumentException("Session not found."); - } - - return session; - } - - protected DtoOptions GetDtoOptions(IAuthorizationContext authContext, object request) - { - var options = new DtoOptions(); - - if (request is IHasItemFields hasFields) - { - options.Fields = hasFields.GetItemFields(); - } - - if (!options.ContainsField(ItemFields.RecursiveItemCount) - || !options.ContainsField(ItemFields.ChildCount)) - { - var client = authContext.GetAuthorizationInfo(Request).Client ?? string.Empty; - if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) - { - int oldLen = options.Fields.Length; - var arr = new ItemFields[oldLen + 1]; - options.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.RecursiveItemCount; - options.Fields = arr; - } - - if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) - { - - int oldLen = options.Fields.Length; - var arr = new ItemFields[oldLen + 1]; - options.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.ChildCount; - options.Fields = arr; - } - } - - if (request is IHasDtoOptions hasDtoOptions) - { - options.EnableImages = hasDtoOptions.EnableImages ?? true; - - if (hasDtoOptions.ImageTypeLimit.HasValue) - { - options.ImageTypeLimit = hasDtoOptions.ImageTypeLimit.Value; - } - - if (hasDtoOptions.EnableUserData.HasValue) - { - options.EnableUserData = hasDtoOptions.EnableUserData.Value; - } - - if (!string.IsNullOrWhiteSpace(hasDtoOptions.EnableImageTypes)) - { - options.ImageTypes = hasDtoOptions.EnableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true)) - .ToArray(); - } - } - - return options; - } - - protected MusicArtist GetArtist(string name, ILibraryManager libraryManager, DtoOptions dtoOptions) - { - if (name.IndexOf(BaseItem.SlugChar) != -1) - { - var result = GetItemFromSlugName<MusicArtist>(libraryManager, name, dtoOptions); - - if (result != null) - { - return result; - } - } - - return libraryManager.GetArtist(name, dtoOptions); - } - - protected Studio GetStudio(string name, ILibraryManager libraryManager, DtoOptions dtoOptions) - { - if (name.IndexOf(BaseItem.SlugChar) != -1) - { - var result = GetItemFromSlugName<Studio>(libraryManager, name, dtoOptions); - - if (result != null) - { - return result; - } - } - - return libraryManager.GetStudio(name); - } - - protected Genre GetGenre(string name, ILibraryManager libraryManager, DtoOptions dtoOptions) - { - if (name.IndexOf(BaseItem.SlugChar) != -1) - { - var result = GetItemFromSlugName<Genre>(libraryManager, name, dtoOptions); - - if (result != null) - { - return result; - } - } - - return libraryManager.GetGenre(name); - } - - protected MusicGenre GetMusicGenre(string name, ILibraryManager libraryManager, DtoOptions dtoOptions) - { - if (name.IndexOf(BaseItem.SlugChar) != -1) - { - var result = GetItemFromSlugName<MusicGenre>(libraryManager, name, dtoOptions); - - if (result != null) - { - return result; - } - } - - return libraryManager.GetMusicGenre(name); - } - - protected Person GetPerson(string name, ILibraryManager libraryManager, DtoOptions dtoOptions) - { - if (name.IndexOf(BaseItem.SlugChar) != -1) - { - var result = GetItemFromSlugName<Person>(libraryManager, name, dtoOptions); - - if (result != null) - { - return result; - } - } - - return libraryManager.GetPerson(name); - } - - private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) - where T : BaseItem, new() - { - var result = libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '&'), - IncludeItemTypes = new[] { typeof(T).Name }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); - - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '/'), - IncludeItemTypes = new[] { typeof(T).Name }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); - - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '?'), - IncludeItemTypes = new[] { typeof(T).Name }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); - - return result; - } - - /// <summary> - /// Gets the path segment at the specified index. - /// </summary> - /// <param name="index">The index of the path segment.</param> - /// <returns>The path segment at the specified index.</returns> - /// <exception cref="IndexOutOfRangeException" >Path doesn't contain enough segments.</exception> - /// <exception cref="InvalidDataException" >Path doesn't start with the base url.</exception> - protected internal ReadOnlySpan<char> GetPathValue(int index) - { - static void ThrowIndexOutOfRangeException() - => throw new IndexOutOfRangeException("Path doesn't contain enough segments."); - - static void ThrowInvalidDataException() - => throw new InvalidDataException("Path doesn't start with the base url."); - - ReadOnlySpan<char> path = Request.PathInfo; - - // Remove the protocol part from the url - int pos = path.LastIndexOf("://"); - if (pos != -1) - { - path = path.Slice(pos + 3); - } - - // Remove the query string - pos = path.LastIndexOf('?'); - if (pos != -1) - { - path = path.Slice(0, pos); - } - - // Remove the domain - pos = path.IndexOf('/'); - if (pos != -1) - { - path = path.Slice(pos); - } - - // Remove base url - string baseUrl = ServerConfigurationManager.Configuration.BaseUrl; - int baseUrlLen = baseUrl.Length; - if (baseUrlLen != 0) - { - if (path.StartsWith(baseUrl, StringComparison.OrdinalIgnoreCase)) - { - path = path.Slice(baseUrlLen); - } - else - { - // The path doesn't start with the base url, - // how did we get here? - ThrowInvalidDataException(); - } - } - - // Remove leading / - path = path.Slice(1); - - // Backwards compatibility - const string Emby = "emby/"; - if (path.StartsWith(Emby, StringComparison.OrdinalIgnoreCase)) - { - path = path.Slice(Emby.Length); - } - - const string MediaBrowser = "mediabrowser/"; - if (path.StartsWith(MediaBrowser, StringComparison.OrdinalIgnoreCase)) - { - path = path.Slice(MediaBrowser.Length); - } - - // Skip segments until we are at the right index - for (int i = 0; i < index; i++) - { - pos = path.IndexOf('/'); - if (pos == -1) - { - ThrowIndexOutOfRangeException(); - } - - path = path.Slice(pos + 1); - } - - // Remove the rest - pos = path.IndexOf('/'); - if (pos != -1) - { - path = path.Slice(0, pos); - } - - return path; - } - - /// <summary> - /// Gets the name of the item by. - /// </summary> - protected BaseItem GetItemByName(string name, string type, ILibraryManager libraryManager, DtoOptions dtoOptions) - { - if (type.Equals("Person", StringComparison.OrdinalIgnoreCase)) - { - return GetPerson(name, libraryManager, dtoOptions); - } - else if (type.Equals("Artist", StringComparison.OrdinalIgnoreCase)) - { - return GetArtist(name, libraryManager, dtoOptions); - } - else if (type.Equals("Genre", StringComparison.OrdinalIgnoreCase)) - { - return GetGenre(name, libraryManager, dtoOptions); - } - else if (type.Equals("MusicGenre", StringComparison.OrdinalIgnoreCase)) - { - return GetMusicGenre(name, libraryManager, dtoOptions); - } - else if (type.Equals("Studio", StringComparison.OrdinalIgnoreCase)) - { - return GetStudio(name, libraryManager, dtoOptions); - } - else if (type.Equals("Year", StringComparison.OrdinalIgnoreCase)) - { - return libraryManager.GetYear(int.Parse(name)); - } - - throw new ArgumentException("Invalid type", nameof(type)); - } - } -} diff --git a/MediaBrowser.Api/BrandingService.cs b/MediaBrowser.Api/BrandingService.cs deleted file mode 100644 index f4724e774..000000000 --- a/MediaBrowser.Api/BrandingService.cs +++ /dev/null @@ -1,44 +0,0 @@ -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Branding; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Branding/Configuration", "GET", Summary = "Gets branding configuration")] - public class GetBrandingOptions : IReturn<BrandingOptions> - { - } - - [Route("/Branding/Css", "GET", Summary = "Gets custom css")] - [Route("/Branding/Css.css", "GET", Summary = "Gets custom css")] - public class GetBrandingCss - { - } - - public class BrandingService : BaseApiService - { - public BrandingService( - ILogger<BrandingService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory) - : base(logger, serverConfigurationManager, httpResultFactory) - { - } - - public object Get(GetBrandingOptions request) - { - return ServerConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - } - - public object Get(GetBrandingCss request) - { - var result = ServerConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - - // When null this throws a 405 error under Mono OSX, so default to empty string - return ResultFactory.GetResult(Request, result.CustomCss ?? string.Empty, "text/css"); - } - } -} diff --git a/MediaBrowser.Api/ChannelService.cs b/MediaBrowser.Api/ChannelService.cs deleted file mode 100644 index 8c336b1c9..000000000 --- a/MediaBrowser.Api/ChannelService.cs +++ /dev/null @@ -1,340 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Api.UserLibrary; -using MediaBrowser.Controller.Channels; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Channels; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Channels", "GET", Summary = "Gets available channels")] - public class GetChannels : IReturn<QueryResult<BaseItemDto>> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "SupportsLatestItems", Description = "Optional. Filter by channels that support getting latest items.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? SupportsLatestItems { get; set; } - - public bool? SupportsMediaDeletion { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is favorite. - /// </summary> - /// <value><c>null</c> if [is favorite] contains no value, <c>true</c> if [is favorite]; otherwise, <c>false</c>.</value> - public bool? IsFavorite { get; set; } - } - - [Route("/Channels/{Id}/Features", "GET", Summary = "Gets features for a channel")] - public class GetChannelFeatures : IReturn<ChannelFeatures> - { - [ApiMember(Name = "Id", Description = "Channel Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Channels/Features", "GET", Summary = "Gets features for a channel")] - public class GetAllChannelFeatures : IReturn<ChannelFeatures[]> - { - } - - [Route("/Channels/{Id}/Items", "GET", Summary = "Gets channel items")] - public class GetChannelItems : IReturn<QueryResult<BaseItemDto>>, IHasItemFields - { - [ApiMember(Name = "Id", Description = "Channel Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "FolderId", Description = "Folder Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string FolderId { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "SortOrder", Description = "Sort Order - Ascending,Descending", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SortOrder { get; set; } - - [ApiMember(Name = "Filters", Description = "Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Filters { get; set; } - - [ApiMember(Name = "SortBy", Description = "Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string SortBy { get; set; } - - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - /// <summary> - /// Gets the filters. - /// </summary> - /// <returns>IEnumerable{ItemFilter}.</returns> - public IEnumerable<ItemFilter> GetFilters() - { - var val = Filters; - - return string.IsNullOrEmpty(val) - ? Array.Empty<ItemFilter>() - : val.Split(',').Select(v => Enum.Parse<ItemFilter>(v, true)); - } - - /// <summary> - /// Gets the order by. - /// </summary> - /// <returns>IEnumerable{ItemSortBy}.</returns> - public ValueTuple<string, SortOrder>[] GetOrderBy() - { - return BaseItemsRequest.GetOrderBy(SortBy, SortOrder); - } - } - - [Route("/Channels/Items/Latest", "GET", Summary = "Gets channel items")] - public class GetLatestChannelItems : IReturn<QueryResult<BaseItemDto>>, IHasItemFields - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "Filters", Description = "Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Filters { get; set; } - - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - [ApiMember(Name = "ChannelIds", Description = "Optional. Specify one or more channel id's, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string ChannelIds { get; set; } - - /// <summary> - /// Gets the filters. - /// </summary> - /// <returns>IEnumerable{ItemFilter}.</returns> - public IEnumerable<ItemFilter> GetFilters() - { - return string.IsNullOrEmpty(Filters) - ? Array.Empty<ItemFilter>() - : Filters.Split(',').Select(v => Enum.Parse<ItemFilter>(v, true)); - } - } - - [Authenticated] - public class ChannelService : BaseApiService - { - private readonly IChannelManager _channelManager; - private IUserManager _userManager; - - public ChannelService( - ILogger<ChannelService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IChannelManager channelManager, - IUserManager userManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _channelManager = channelManager; - _userManager = userManager; - } - - public object Get(GetAllChannelFeatures request) - { - var result = _channelManager.GetAllChannelFeatures(); - - return ToOptimizedResult(result); - } - - public object Get(GetChannelFeatures request) - { - var result = _channelManager.GetChannelFeatures(request.Id); - - return ToOptimizedResult(result); - } - - public object Get(GetChannels request) - { - var result = _channelManager.GetChannels(new ChannelQuery - { - Limit = request.Limit, - StartIndex = request.StartIndex, - UserId = request.UserId, - SupportsLatestItems = request.SupportsLatestItems, - SupportsMediaDeletion = request.SupportsMediaDeletion, - IsFavorite = request.IsFavorite - }); - - return ToOptimizedResult(result); - } - - public async Task<object> Get(GetChannelItems request) - { - var user = request.UserId.Equals(Guid.Empty) - ? null - : _userManager.GetUserById(request.UserId); - - var query = new InternalItemsQuery(user) - { - Limit = request.Limit, - StartIndex = request.StartIndex, - ChannelIds = new[] { new Guid(request.Id) }, - ParentId = string.IsNullOrWhiteSpace(request.FolderId) ? Guid.Empty : new Guid(request.FolderId), - OrderBy = request.GetOrderBy(), - DtoOptions = new Controller.Dto.DtoOptions - { - Fields = request.GetItemFields() - } - }; - - foreach (var filter in request.GetFilters()) - { - 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 = await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Get(GetLatestChannelItems request) - { - var user = request.UserId.Equals(Guid.Empty) - ? null - : _userManager.GetUserById(request.UserId); - - var query = new InternalItemsQuery(user) - { - Limit = request.Limit, - StartIndex = request.StartIndex, - ChannelIds = (request.ChannelIds ?? string.Empty).Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).Select(i => new Guid(i)).ToArray(), - DtoOptions = new Controller.Dto.DtoOptions - { - Fields = request.GetItemFields() - } - }; - - foreach (var filter in request.GetFilters()) - { - 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 = await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - } -} diff --git a/MediaBrowser.Api/ConfigurationService.cs b/MediaBrowser.Api/ConfigurationService.cs deleted file mode 100644 index 19369ccca..000000000 --- a/MediaBrowser.Api/ConfigurationService.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.IO; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class GetConfiguration. - /// </summary> - [Route("/System/Configuration", "GET", Summary = "Gets application configuration")] - [Authenticated] - public class GetConfiguration : IReturn<ServerConfiguration> - { - } - - [Route("/System/Configuration/{Key}", "GET", Summary = "Gets a named configuration")] - [Authenticated(AllowBeforeStartupWizard = true)] - public class GetNamedConfiguration - { - [ApiMember(Name = "Key", Description = "Key", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Key { get; set; } - } - - /// <summary> - /// Class UpdateConfiguration. - /// </summary> - [Route("/System/Configuration", "POST", Summary = "Updates application configuration")] - [Authenticated(Roles = "Admin")] - public class UpdateConfiguration : ServerConfiguration, IReturnVoid - { - } - - [Route("/System/Configuration/{Key}", "POST", Summary = "Updates named configuration")] - [Authenticated(Roles = "Admin")] - public class UpdateNamedConfiguration : IReturnVoid, IRequiresRequestStream - { - [ApiMember(Name = "Key", Description = "Key", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Key { get; set; } - - public Stream RequestStream { get; set; } - } - - [Route("/System/Configuration/MetadataOptions/Default", "GET", Summary = "Gets a default MetadataOptions object")] - [Authenticated(Roles = "Admin")] - public class GetDefaultMetadataOptions : IReturn<MetadataOptions> - { - } - - [Route("/System/MediaEncoder/Path", "POST", Summary = "Updates the path to the media encoder")] - [Authenticated(Roles = "Admin", AllowBeforeStartupWizard = true)] - public class UpdateMediaEncoderPath : IReturnVoid - { - [ApiMember(Name = "Path", Description = "Path", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Path { get; set; } - [ApiMember(Name = "PathType", Description = "PathType", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string PathType { get; set; } - } - - public class ConfigurationService : BaseApiService - { - /// <summary> - /// The _json serializer. - /// </summary> - private readonly IJsonSerializer _jsonSerializer; - - /// <summary> - /// The _configuration manager. - /// </summary> - private readonly IServerConfigurationManager _configurationManager; - - private readonly IMediaEncoder _mediaEncoder; - - public ConfigurationService( - ILogger<ConfigurationService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IJsonSerializer jsonSerializer, - IServerConfigurationManager configurationManager, - IMediaEncoder mediaEncoder) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _jsonSerializer = jsonSerializer; - _configurationManager = configurationManager; - _mediaEncoder = mediaEncoder; - } - - public void Post(UpdateMediaEncoderPath request) - { - _mediaEncoder.UpdateEncoderPath(request.Path, request.PathType); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetConfiguration request) - { - return ToOptimizedResult(_configurationManager.Configuration); - } - - public object Get(GetNamedConfiguration request) - { - var result = _configurationManager.GetConfiguration(request.Key); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Posts the specified configuraiton. - /// </summary> - /// <param name="request">The request.</param> - public void Post(UpdateConfiguration request) - { - // Silly, but we need to serialize and deserialize or the XmlSerializer will write the xml with an element name of UpdateConfiguration - var json = _jsonSerializer.SerializeToString(request); - - var config = _jsonSerializer.DeserializeFromString<ServerConfiguration>(json); - - _configurationManager.ReplaceConfiguration(config); - } - - public async Task Post(UpdateNamedConfiguration request) - { - var key = GetPathValue(2).ToString(); - - var configurationType = _configurationManager.GetConfigurationType(key); - var configuration = await _jsonSerializer.DeserializeFromStreamAsync(request.RequestStream, configurationType).ConfigureAwait(false); - - _configurationManager.SaveConfiguration(key, configuration); - } - - public object Get(GetDefaultMetadataOptions request) - { - return ToOptimizedResult(new MetadataOptions()); - } - } -} diff --git a/MediaBrowser.Api/Devices/DeviceService.cs b/MediaBrowser.Api/Devices/DeviceService.cs deleted file mode 100644 index 18860983e..000000000 --- a/MediaBrowser.Api/Devices/DeviceService.cs +++ /dev/null @@ -1,103 +0,0 @@ -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Devices; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Devices -{ - [Route("/Devices", "GET", Summary = "Gets all devices")] - [Authenticated(Roles = "Admin")] - public class GetDevices : DeviceQuery, IReturn<QueryResult<DeviceInfo>> - { - } - - [Route("/Devices/Info", "GET", Summary = "Gets info for a device")] - [Authenticated(Roles = "Admin")] - public class GetDeviceInfo : IReturn<DeviceInfo> - { - [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Devices/Options", "GET", Summary = "Gets options for a device")] - [Authenticated(Roles = "Admin")] - public class GetDeviceOptions : IReturn<DeviceOptions> - { - [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Devices", "DELETE", Summary = "Deletes a device")] - public class DeleteDevice - { - [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/Devices/Options", "POST", Summary = "Updates device options")] - [Authenticated(Roles = "Admin")] - public class PostDeviceOptions : DeviceOptions, IReturnVoid - { - [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string Id { get; set; } - } - - public class DeviceService : BaseApiService - { - private readonly IDeviceManager _deviceManager; - private readonly IAuthenticationRepository _authRepo; - private readonly ISessionManager _sessionManager; - - public DeviceService( - ILogger<DeviceService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IDeviceManager deviceManager, - IAuthenticationRepository authRepo, - ISessionManager sessionManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _deviceManager = deviceManager; - _authRepo = authRepo; - _sessionManager = sessionManager; - } - - public void Post(PostDeviceOptions request) - { - _deviceManager.UpdateDeviceOptions(request.Id, request); - } - - public object Get(GetDevices request) - { - return ToOptimizedResult(_deviceManager.GetDevices(request)); - } - - public object Get(GetDeviceInfo request) - { - return _deviceManager.GetDevice(request.Id); - } - - public object Get(GetDeviceOptions request) - { - return _deviceManager.GetDeviceOptions(request.Id); - } - - public void Delete(DeleteDevice request) - { - var sessions = _authRepo.Get(new AuthenticationInfoQuery - { - DeviceId = request.Id - }).Items; - - foreach (var session in sessions) - { - _sessionManager.Logout(session); - } - } - } -} diff --git a/MediaBrowser.Api/DisplayPreferencesService.cs b/MediaBrowser.Api/DisplayPreferencesService.cs deleted file mode 100644 index c3ed40ad3..000000000 --- a/MediaBrowser.Api/DisplayPreferencesService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Threading; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class UpdateDisplayPreferences. - /// </summary> - [Route("/DisplayPreferences/{DisplayPreferencesId}", "POST", Summary = "Updates a user's display preferences for an item")] - public class UpdateDisplayPreferences : DisplayPreferences, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "DisplayPreferencesId", Description = "DisplayPreferences Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string DisplayPreferencesId { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string UserId { get; set; } - } - - [Route("/DisplayPreferences/{Id}", "GET", Summary = "Gets a user's display preferences for an item")] - public class GetDisplayPreferences : IReturn<DisplayPreferences> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string UserId { get; set; } - - [ApiMember(Name = "Client", Description = "Client", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Client { get; set; } - } - - /// <summary> - /// Class DisplayPreferencesService. - /// </summary> - [Authenticated] - public class DisplayPreferencesService : BaseApiService - { - /// <summary> - /// The _display preferences manager. - /// </summary> - private readonly IDisplayPreferencesRepository _displayPreferencesManager; - /// <summary> - /// The _json serializer. - /// </summary> - private readonly IJsonSerializer _jsonSerializer; - - /// <summary> - /// Initializes a new instance of the <see cref="DisplayPreferencesService" /> class. - /// </summary> - /// <param name="jsonSerializer">The json serializer.</param> - /// <param name="displayPreferencesManager">The display preferences manager.</param> - public DisplayPreferencesService( - ILogger<DisplayPreferencesService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IJsonSerializer jsonSerializer, - IDisplayPreferencesRepository displayPreferencesManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _jsonSerializer = jsonSerializer; - _displayPreferencesManager = displayPreferencesManager; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Get(GetDisplayPreferences request) - { - var result = _displayPreferencesManager.GetDisplayPreferences(request.Id, request.UserId, request.Client); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(UpdateDisplayPreferences request) - { - // Serialize to json and then back so that the core doesn't see the request dto type - var displayPreferences = _jsonSerializer.DeserializeFromString<DisplayPreferences>(_jsonSerializer.SerializeToString(request)); - - _displayPreferencesManager.SaveDisplayPreferences(displayPreferences, request.UserId, request.Client, CancellationToken.None); - } - } -} diff --git a/MediaBrowser.Api/EnvironmentService.cs b/MediaBrowser.Api/EnvironmentService.cs deleted file mode 100644 index 720a71025..000000000 --- a/MediaBrowser.Api/EnvironmentService.cs +++ /dev/null @@ -1,285 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class GetDirectoryContents. - /// </summary> - [Route("/Environment/DirectoryContents", "GET", Summary = "Gets the contents of a given directory in the file system")] - public class GetDirectoryContents : IReturn<List<FileSystemEntryInfo>> - { - /// <summary> - /// Gets or sets the path. - /// </summary> - /// <value>The path.</value> - [ApiMember(Name = "Path", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Path { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [include files]. - /// </summary> - /// <value><c>true</c> if [include files]; otherwise, <c>false</c>.</value> - [ApiMember(Name = "IncludeFiles", Description = "An optional filter to include or exclude files from the results. true/false", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool IncludeFiles { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [include directories]. - /// </summary> - /// <value><c>true</c> if [include directories]; otherwise, <c>false</c>.</value> - [ApiMember(Name = "IncludeDirectories", Description = "An optional filter to include or exclude folders from the results. true/false", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool IncludeDirectories { get; set; } - } - - [Route("/Environment/ValidatePath", "POST", Summary = "Gets the contents of a given directory in the file system")] - public class ValidatePath - { - /// <summary> - /// Gets or sets the path. - /// </summary> - /// <value>The path.</value> - [ApiMember(Name = "Path", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Path { get; set; } - - public bool ValidateWriteable { get; set; } - - public bool? IsFile { get; set; } - } - - [Obsolete] - [Route("/Environment/NetworkShares", "GET", Summary = "Gets shares from a network device")] - public class GetNetworkShares : IReturn<List<FileSystemEntryInfo>> - { - /// <summary> - /// Gets or sets the path. - /// </summary> - /// <value>The path.</value> - [ApiMember(Name = "Path", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Path { get; set; } - } - - /// <summary> - /// Class GetDrives. - /// </summary> - [Route("/Environment/Drives", "GET", Summary = "Gets available drives from the server's file system")] - public class GetDrives : IReturn<List<FileSystemEntryInfo>> - { - } - - /// <summary> - /// Class GetNetworkComputers. - /// </summary> - [Route("/Environment/NetworkDevices", "GET", Summary = "Gets a list of devices on the network")] - public class GetNetworkDevices : IReturn<List<FileSystemEntryInfo>> - { - } - - [Route("/Environment/ParentPath", "GET", Summary = "Gets the parent path of a given path")] - public class GetParentPath : IReturn<string> - { - /// <summary> - /// Gets or sets the path. - /// </summary> - /// <value>The path.</value> - [ApiMember(Name = "Path", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Path { get; set; } - } - - public class DefaultDirectoryBrowserInfo - { - public string Path { get; set; } - } - - [Route("/Environment/DefaultDirectoryBrowser", "GET", Summary = "Gets the parent path of a given path")] - public class GetDefaultDirectoryBrowser : IReturn<DefaultDirectoryBrowserInfo> - { - } - - /// <summary> - /// Class EnvironmentService. - /// </summary> - [Authenticated(Roles = "Admin", AllowBeforeStartupWizard = true)] - public class EnvironmentService : BaseApiService - { - private const char UncSeparator = '\\'; - private const string UncSeparatorString = "\\"; - - /// <summary> - /// The _network manager. - /// </summary> - private readonly INetworkManager _networkManager; - private readonly IFileSystem _fileSystem; - - /// <summary> - /// Initializes a new instance of the <see cref="EnvironmentService" /> class. - /// </summary> - /// <param name="networkManager">The network manager.</param> - public EnvironmentService( - ILogger<EnvironmentService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - INetworkManager networkManager, - IFileSystem fileSystem) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _networkManager = networkManager; - _fileSystem = fileSystem; - } - - public void Post(ValidatePath request) - { - if (request.IsFile.HasValue) - { - if (request.IsFile.Value) - { - if (!File.Exists(request.Path)) - { - throw new FileNotFoundException("File not found", request.Path); - } - } - else - { - if (!Directory.Exists(request.Path)) - { - throw new FileNotFoundException("File not found", request.Path); - } - } - } - - else - { - if (!File.Exists(request.Path) && !Directory.Exists(request.Path)) - { - throw new FileNotFoundException("Path not found", request.Path); - } - - if (request.ValidateWriteable) - { - EnsureWriteAccess(request.Path); - } - } - } - - protected void EnsureWriteAccess(string path) - { - var file = Path.Combine(path, Guid.NewGuid().ToString()); - - File.WriteAllText(file, string.Empty); - _fileSystem.DeleteFile(file); - } - - public object Get(GetDefaultDirectoryBrowser request) => - ToOptimizedResult(new DefaultDirectoryBrowserInfo { Path = null }); - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetDirectoryContents request) - { - var path = request.Path; - - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(Path)); - } - - var networkPrefix = UncSeparatorString + UncSeparatorString; - - if (path.StartsWith(networkPrefix, StringComparison.OrdinalIgnoreCase) - && path.LastIndexOf(UncSeparator) == 1) - { - return ToOptimizedResult(Array.Empty<FileSystemEntryInfo>()); - } - - return ToOptimizedResult(GetFileSystemEntries(request).ToList()); - } - - [Obsolete] - public object Get(GetNetworkShares request) - => ToOptimizedResult(Array.Empty<FileSystemEntryInfo>()); - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetDrives request) - { - var result = GetDrives().ToList(); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the list that is returned when an empty path is supplied. - /// </summary> - /// <returns>IEnumerable{FileSystemEntryInfo}.</returns> - private IEnumerable<FileSystemEntryInfo> GetDrives() - { - return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory)); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetNetworkDevices request) - => ToOptimizedResult(Array.Empty<FileSystemEntryInfo>()); - - /// <summary> - /// Gets the file system entries. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>IEnumerable{FileSystemEntryInfo}.</returns> - private IEnumerable<FileSystemEntryInfo> GetFileSystemEntries(GetDirectoryContents request) - { - var entries = _fileSystem.GetFileSystemEntries(request.Path).OrderBy(i => i.FullName).Where(i => - { - var isDirectory = i.IsDirectory; - - if (!request.IncludeFiles && !isDirectory) - { - return false; - } - - return request.IncludeDirectories || !isDirectory; - }); - - return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File)); - } - - public object Get(GetParentPath request) - { - var parent = Path.GetDirectoryName(request.Path); - - if (string.IsNullOrEmpty(parent)) - { - // Check if unc share - var index = request.Path.LastIndexOf(UncSeparator); - - if (index != -1 && request.Path.IndexOf(UncSeparator) == 0) - { - parent = request.Path.Substring(0, index); - - if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator))) - { - parent = null; - } - } - } - - return parent; - } - } -} diff --git a/MediaBrowser.Api/FilterService.cs b/MediaBrowser.Api/FilterService.cs deleted file mode 100644 index dcfdcbfed..000000000 --- a/MediaBrowser.Api/FilterService.cs +++ /dev/null @@ -1,248 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Data.Entities; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Items/Filters", "GET", Summary = "Gets branding configuration")] - public class GetQueryFiltersLegacy : IReturn<QueryFiltersLegacy> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ParentId { get; set; } - - [ApiMember(Name = "IncludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string IncludeItemTypes { get; set; } - - [ApiMember(Name = "MediaTypes", Description = "Optional filter by MediaType. Allows multiple, comma delimited.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string MediaTypes { get; set; } - - public string[] GetMediaTypes() - { - return (MediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetIncludeItemTypes() - { - return (IncludeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - } - - [Route("/Items/Filters2", "GET", Summary = "Gets branding configuration")] - public class GetQueryFilters : IReturn<QueryFilters> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ParentId { get; set; } - - [ApiMember(Name = "IncludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string IncludeItemTypes { get; set; } - - [ApiMember(Name = "MediaTypes", Description = "Optional filter by MediaType. Allows multiple, comma delimited.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string MediaTypes { get; set; } - - public string[] GetMediaTypes() - { - return (MediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetIncludeItemTypes() - { - return (IncludeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - public bool? IsAiring { get; set; } - - public bool? IsMovie { get; set; } - - public bool? IsSports { get; set; } - - public bool? IsKids { get; set; } - - public bool? IsNews { get; set; } - - public bool? IsSeries { get; set; } - - public bool? Recursive { get; set; } - } - - [Authenticated] - public class FilterService : BaseApiService - { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - - public FilterService( - ILogger<FilterService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ILibraryManager libraryManager, - IUserManager userManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _libraryManager = libraryManager; - _userManager = userManager; - } - - public object Get(GetQueryFilters request) - { - var parentItem = string.IsNullOrEmpty(request.ParentId) ? null : _libraryManager.GetItemById(request.ParentId); - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - if (string.Equals(request.IncludeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase) || - string.Equals(request.IncludeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase) || - string.Equals(request.IncludeItemTypes, typeof(Trailer).Name, StringComparison.OrdinalIgnoreCase) || - string.Equals(request.IncludeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) - { - parentItem = null; - } - - var filters = new QueryFilters(); - - var genreQuery = new InternalItemsQuery(user) - { - IncludeItemTypes = request.GetIncludeItemTypes(), - DtoOptions = new Controller.Dto.DtoOptions - { - Fields = Array.Empty<ItemFields>(), - EnableImages = false, - EnableUserData = false - }, - IsAiring = request.IsAiring, - IsMovie = request.IsMovie, - IsSports = request.IsSports, - IsKids = request.IsKids, - IsNews = request.IsNews, - IsSeries = request.IsSeries - }; - - // Non recursive not yet supported for library folders - if ((request.Recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) - { - genreQuery.AncestorIds = parentItem == null ? Array.Empty<Guid>() : new[] { parentItem.Id }; - } - else - { - genreQuery.Parent = parentItem; - } - - if (string.Equals(request.IncludeItemTypes, "MusicAlbum", StringComparison.OrdinalIgnoreCase) || - string.Equals(request.IncludeItemTypes, "MusicVideo", StringComparison.OrdinalIgnoreCase) || - string.Equals(request.IncludeItemTypes, "MusicArtist", StringComparison.OrdinalIgnoreCase) || - string.Equals(request.IncludeItemTypes, "Audio", StringComparison.OrdinalIgnoreCase)) - { - filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair - { - Name = i.Item1.Name, - Id = i.Item1.Id - }).ToArray(); - } - else - { - filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair - { - Name = i.Item1.Name, - Id = i.Item1.Id - }).ToArray(); - } - - return ToOptimizedResult(filters); - } - - public object Get(GetQueryFiltersLegacy request) - { - var parentItem = string.IsNullOrEmpty(request.ParentId) ? null : _libraryManager.GetItemById(request.ParentId); - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - if (string.Equals(request.IncludeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase) || - string.Equals(request.IncludeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase) || - string.Equals(request.IncludeItemTypes, typeof(Trailer).Name, StringComparison.OrdinalIgnoreCase) || - string.Equals(request.IncludeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) - { - parentItem = null; - } - - var item = string.IsNullOrEmpty(request.ParentId) ? - user == null ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder() : - parentItem; - - var result = ((Folder)item).GetItemList(GetItemsQuery(request, user)); - - var filters = GetFilters(result); - - return ToOptimizedResult(filters); - } - - private QueryFiltersLegacy GetFilters(IReadOnlyCollection<BaseItem> items) - { - var result = new QueryFiltersLegacy(); - - result.Years = items.Select(i => i.ProductionYear ?? -1) - .Where(i => i > 0) - .Distinct() - .OrderBy(i => i) - .ToArray(); - - result.Genres = items.SelectMany(i => i.Genres) - .DistinctNames() - .OrderBy(i => i) - .ToArray(); - - result.Tags = items - .SelectMany(i => i.Tags) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(i => i) - .ToArray(); - - result.OfficialRatings = items - .Select(i => i.OfficialRating) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(i => i) - .ToArray(); - - return result; - } - - private InternalItemsQuery GetItemsQuery(GetQueryFiltersLegacy request, User user) - { - var query = new InternalItemsQuery - { - User = user, - MediaTypes = request.GetMediaTypes(), - IncludeItemTypes = request.GetIncludeItemTypes(), - Recursive = true, - EnableTotalRecordCount = false, - DtoOptions = new Controller.Dto.DtoOptions - { - Fields = new[] { ItemFields.Genres, ItemFields.Tags }, - EnableImages = false, - EnableUserData = false - } - }; - - return query; - } - } -} diff --git a/MediaBrowser.Api/IHasDtoOptions.cs b/MediaBrowser.Api/IHasDtoOptions.cs deleted file mode 100644 index 33d498e8b..000000000 --- a/MediaBrowser.Api/IHasDtoOptions.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace MediaBrowser.Api -{ - public interface IHasDtoOptions : IHasItemFields - { - bool? EnableImages { get; set; } - - bool? EnableUserData { get; set; } - - int? ImageTypeLimit { get; set; } - - string EnableImageTypes { get; set; } - } -} diff --git a/MediaBrowser.Api/IHasItemFields.cs b/MediaBrowser.Api/IHasItemFields.cs deleted file mode 100644 index ad4f1b489..000000000 --- a/MediaBrowser.Api/IHasItemFields.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Linq; -using MediaBrowser.Model.Querying; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Interface IHasItemFields. - /// </summary> - public interface IHasItemFields - { - /// <summary> - /// Gets or sets the fields. - /// </summary> - /// <value>The fields.</value> - string Fields { get; set; } - } - - /// <summary> - /// Class ItemFieldsExtensions. - /// </summary> - public static class ItemFieldsExtensions - { - /// <summary> - /// Gets the item fields. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>IEnumerable{ItemFields}.</returns> - public static ItemFields[] GetItemFields(this IHasItemFields request) - { - var val = request.Fields; - - if (string.IsNullOrEmpty(val)) - { - return Array.Empty<ItemFields>(); - } - - return val.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(); - } - } -} diff --git a/MediaBrowser.Api/Images/ImageByNameService.cs b/MediaBrowser.Api/Images/ImageByNameService.cs deleted file mode 100644 index 2d405ac3d..000000000 --- a/MediaBrowser.Api/Images/ImageByNameService.cs +++ /dev/null @@ -1,277 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Images -{ - /// <summary> - /// Class GetGeneralImage. - /// </summary> - [Route("/Images/General/{Name}/{Type}", "GET", Summary = "Gets a general image by name")] - public class GetGeneralImage - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "The name of the image", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - [ApiMember(Name = "Type", Description = "Image Type (primary, backdrop, logo, etc).", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Type { get; set; } - } - - /// <summary> - /// Class GetRatingImage. - /// </summary> - [Route("/Images/Ratings/{Theme}/{Name}", "GET", Summary = "Gets a rating image by name")] - public class GetRatingImage - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "The name of the image", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - /// <summary> - /// Gets or sets the theme. - /// </summary> - /// <value>The theme.</value> - [ApiMember(Name = "Theme", Description = "The theme to get the image from", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Theme { get; set; } - } - - /// <summary> - /// Class GetMediaInfoImage. - /// </summary> - [Route("/Images/MediaInfo/{Theme}/{Name}", "GET", Summary = "Gets a media info image by name")] - public class GetMediaInfoImage - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "The name of the image", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - /// <summary> - /// Gets or sets the theme. - /// </summary> - /// <value>The theme.</value> - [ApiMember(Name = "Theme", Description = "The theme to get the image from", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Theme { get; set; } - } - - [Route("/Images/MediaInfo", "GET", Summary = "Gets all media info image by name")] - [Authenticated] - public class GetMediaInfoImages : IReturn<List<ImageByNameInfo>> - { - } - - [Route("/Images/Ratings", "GET", Summary = "Gets all rating images by name")] - [Authenticated] - public class GetRatingImages : IReturn<List<ImageByNameInfo>> - { - } - - [Route("/Images/General", "GET", Summary = "Gets all general images by name")] - [Authenticated] - public class GetGeneralImages : IReturn<List<ImageByNameInfo>> - { - } - - /// <summary> - /// Class ImageByNameService. - /// </summary> - public class ImageByNameService : BaseApiService - { - /// <summary> - /// The _app paths. - /// </summary> - private readonly IServerApplicationPaths _appPaths; - - private readonly IFileSystem _fileSystem; - - /// <summary> - /// Initializes a new instance of the <see cref="ImageByNameService" /> class. - /// </summary> - public ImageByNameService( - ILogger<ImageByNameService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory resultFactory, - IFileSystem fileSystem) - : base(logger, serverConfigurationManager, resultFactory) - { - _appPaths = serverConfigurationManager.ApplicationPaths; - _fileSystem = fileSystem; - } - - public object Get(GetMediaInfoImages request) - { - return ToOptimizedResult(GetImageList(_appPaths.MediaInfoImagesPath, true)); - } - - public object Get(GetRatingImages request) - { - return ToOptimizedResult(GetImageList(_appPaths.RatingsPath, true)); - } - - public object Get(GetGeneralImages request) - { - return ToOptimizedResult(GetImageList(_appPaths.GeneralPath, false)); - } - - private List<ImageByNameInfo> GetImageList(string path, bool supportsThemes) - { - try - { - return _fileSystem.GetFiles(path, BaseItem.SupportedImageExtensions, false, true) - .Select(i => new ImageByNameInfo - { - Name = _fileSystem.GetFileNameWithoutExtension(i), - FileLength = i.Length, - - // For themeable images, use the Theme property - // For general images, the same object structure is fine, - // but it's not owned by a theme, so call it Context - Theme = supportsThemes ? GetThemeName(i.FullName, path) : null, - Context = supportsThemes ? null : GetThemeName(i.FullName, path), - - Format = i.Extension.ToLowerInvariant().TrimStart('.') - }) - .OrderBy(i => i.Name) - .ToList(); - } - catch (IOException) - { - return new List<ImageByNameInfo>(); - } - } - - private string GetThemeName(string path, string rootImagePath) - { - var parentName = Path.GetDirectoryName(path); - - if (string.Equals(parentName, rootImagePath, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - parentName = Path.GetFileName(parentName); - - return string.Equals(parentName, "all", StringComparison.OrdinalIgnoreCase) ? - null : - parentName; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public Task<object> Get(GetGeneralImage request) - { - var filename = string.Equals(request.Type, "primary", StringComparison.OrdinalIgnoreCase) - ? "folder" - : request.Type; - - var paths = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(_appPaths.GeneralPath, request.Name, filename + i)).ToList(); - - var path = paths.FirstOrDefault(File.Exists) ?? paths.FirstOrDefault(); - - return ResultFactory.GetStaticFileResult(Request, path); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetRatingImage request) - { - var themeFolder = Path.Combine(_appPaths.RatingsPath, request.Theme); - - if (Directory.Exists(themeFolder)) - { - var path = BaseItem.SupportedImageExtensions - .Select(i => Path.Combine(themeFolder, request.Name + i)) - .FirstOrDefault(File.Exists); - - if (!string.IsNullOrEmpty(path)) - { - return ResultFactory.GetStaticFileResult(Request, path); - } - } - - var allFolder = Path.Combine(_appPaths.RatingsPath, "all"); - - if (Directory.Exists(allFolder)) - { - // Avoid implicitly captured closure - var currentRequest = request; - - var path = BaseItem.SupportedImageExtensions - .Select(i => Path.Combine(allFolder, currentRequest.Name + i)) - .FirstOrDefault(File.Exists); - - if (!string.IsNullOrEmpty(path)) - { - return ResultFactory.GetStaticFileResult(Request, path); - } - } - - throw new ResourceNotFoundException("MediaInfo image not found: " + request.Name); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public Task<object> Get(GetMediaInfoImage request) - { - var themeFolder = Path.Combine(_appPaths.MediaInfoImagesPath, request.Theme); - - if (Directory.Exists(themeFolder)) - { - var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(themeFolder, request.Name + i)) - .FirstOrDefault(File.Exists); - - if (!string.IsNullOrEmpty(path)) - { - return ResultFactory.GetStaticFileResult(Request, path); - } - } - - var allFolder = Path.Combine(_appPaths.MediaInfoImagesPath, "all"); - - if (Directory.Exists(allFolder)) - { - // Avoid implicitly captured closure - var currentRequest = request; - - var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(allFolder, currentRequest.Name + i)) - .FirstOrDefault(File.Exists); - - if (!string.IsNullOrEmpty(path)) - { - return ResultFactory.GetStaticFileResult(Request, path); - } - } - - throw new ResourceNotFoundException("MediaInfo image not found: " + request.Name); - } - } -} diff --git a/MediaBrowser.Api/Images/ImageRequest.cs b/MediaBrowser.Api/Images/ImageRequest.cs deleted file mode 100644 index 0f3455548..000000000 --- a/MediaBrowser.Api/Images/ImageRequest.cs +++ /dev/null @@ -1,100 +0,0 @@ -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Services; - -namespace MediaBrowser.Api.Images -{ - /// <summary> - /// Class ImageRequest. - /// </summary> - public class ImageRequest : DeleteImageRequest - { - /// <summary> - /// The max width. - /// </summary> - [ApiMember(Name = "MaxWidth", Description = "The maximum image width to return.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? MaxWidth { get; set; } - - /// <summary> - /// The max height. - /// </summary> - [ApiMember(Name = "MaxHeight", Description = "The maximum image height to return.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? MaxHeight { get; set; } - - /// <summary> - /// The width. - /// </summary> - [ApiMember(Name = "Width", Description = "The fixed image width to return.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Width { get; set; } - - /// <summary> - /// The height. - /// </summary> - [ApiMember(Name = "Height", Description = "The fixed image height to return.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Height { get; set; } - - /// <summary> - /// Gets or sets the quality. - /// </summary> - /// <value>The quality.</value> - [ApiMember(Name = "Quality", Description = "Optional quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Quality { get; set; } - - /// <summary> - /// Gets or sets the tag. - /// </summary> - /// <value>The tag.</value> - [ApiMember(Name = "Tag", Description = "Optional. Supply the cache tag from the item object to receive strong caching headers.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Tag { get; set; } - - [ApiMember(Name = "CropWhitespace", Description = "Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? CropWhitespace { get; set; } - - [ApiMember(Name = "EnableImageEnhancers", Description = "Enable or disable image enhancers such as cover art.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool EnableImageEnhancers { get; set; } - - [ApiMember(Name = "Format", Description = "Determines the output foramt of the image - original,gif,jpg,png", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public string Format { get; set; } - - [ApiMember(Name = "AddPlayedIndicator", Description = "Optional. Add a played indicator", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool AddPlayedIndicator { get; set; } - - [ApiMember(Name = "PercentPlayed", Description = "Optional percent to render for the percent played overlay", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public double? PercentPlayed { get; set; } - - [ApiMember(Name = "UnplayedCount", Description = "Optional unplayed count overlay to render", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? UnplayedCount { get; set; } - - public int? Blur { get; set; } - - [ApiMember(Name = "BackgroundColor", Description = "Optional. Apply a background color for transparent images.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string BackgroundColor { get; set; } - - [ApiMember(Name = "ForegroundLayer", Description = "Optional. Apply a foreground layer on top of the image.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ForegroundLayer { get; set; } - - public ImageRequest() - { - EnableImageEnhancers = true; - } - } - - /// <summary> - /// Class DeleteImageRequest. - /// </summary> - public class DeleteImageRequest - { - /// <summary> - /// Gets or sets the type of the image. - /// </summary> - /// <value>The type of the image.</value> - [ApiMember(Name = "Type", Description = "Image Type", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET,POST,DELETE")] - public ImageType Type { get; set; } - - /// <summary> - /// Gets or sets the index. - /// </summary> - /// <value>The index.</value> - [ApiMember(Name = "Index", Description = "Image Index", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET,POST,DELETE")] - public int? Index { get; set; } - } -} diff --git a/MediaBrowser.Api/Images/ImageService.cs b/MediaBrowser.Api/Images/ImageService.cs deleted file mode 100644 index 575b1157a..000000000 --- a/MediaBrowser.Api/Images/ImageService.cs +++ /dev/null @@ -1,911 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Drawing; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; -using User = Jellyfin.Data.Entities.User; - -namespace MediaBrowser.Api.Images -{ - /// <summary> - /// Class GetItemImage. - /// </summary> - [Route("/Items/{Id}/Images", "GET", Summary = "Gets information about an item's images")] - [Authenticated] - public class GetItemImageInfos : IReturn<List<ImageInfo>> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Items/{Id}/Images/{Type}", "GET")] - [Route("/Items/{Id}/Images/{Type}/{Index}", "GET")] - [Route("/Items/{Id}/Images/{Type}", "HEAD")] - [Route("/Items/{Id}/Images/{Type}/{Index}", "HEAD")] - [Route("/Items/{Id}/Images/{Type}/{Index}/{Tag}/{Format}/{MaxWidth}/{MaxHeight}/{PercentPlayed}/{UnplayedCount}", "GET")] - [Route("/Items/{Id}/Images/{Type}/{Index}/{Tag}/{Format}/{MaxWidth}/{MaxHeight}/{PercentPlayed}/{UnplayedCount}", "HEAD")] - public class GetItemImage : ImageRequest - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class UpdateItemImageIndex. - /// </summary> - [Route("/Items/{Id}/Images/{Type}/{Index}/Index", "POST", Summary = "Updates the index for an item image")] - [Authenticated(Roles = "admin")] - public class UpdateItemImageIndex : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the type of the image. - /// </summary> - /// <value>The type of the image.</value> - [ApiMember(Name = "Type", Description = "Image Type", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public ImageType Type { get; set; } - - /// <summary> - /// Gets or sets the index. - /// </summary> - /// <value>The index.</value> - [ApiMember(Name = "Index", Description = "Image Index", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")] - public int Index { get; set; } - - /// <summary> - /// Gets or sets the new index. - /// </summary> - /// <value>The new index.</value> - [ApiMember(Name = "NewIndex", Description = "The new image index", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public int NewIndex { get; set; } - } - - /// <summary> - /// Class GetPersonImage. - /// </summary> - [Route("/Artists/{Name}/Images/{Type}", "GET")] - [Route("/Artists/{Name}/Images/{Type}/{Index}", "GET")] - [Route("/Genres/{Name}/Images/{Type}", "GET")] - [Route("/Genres/{Name}/Images/{Type}/{Index}", "GET")] - [Route("/MusicGenres/{Name}/Images/{Type}", "GET")] - [Route("/MusicGenres/{Name}/Images/{Type}/{Index}", "GET")] - [Route("/Persons/{Name}/Images/{Type}", "GET")] - [Route("/Persons/{Name}/Images/{Type}/{Index}", "GET")] - [Route("/Studios/{Name}/Images/{Type}", "GET")] - [Route("/Studios/{Name}/Images/{Type}/{Index}", "GET")] - ////[Route("/Years/{Year}/Images/{Type}", "GET")] - ////[Route("/Years/{Year}/Images/{Type}/{Index}", "GET")] - [Route("/Artists/{Name}/Images/{Type}", "HEAD")] - [Route("/Artists/{Name}/Images/{Type}/{Index}", "HEAD")] - [Route("/Genres/{Name}/Images/{Type}", "HEAD")] - [Route("/Genres/{Name}/Images/{Type}/{Index}", "HEAD")] - [Route("/MusicGenres/{Name}/Images/{Type}", "HEAD")] - [Route("/MusicGenres/{Name}/Images/{Type}/{Index}", "HEAD")] - [Route("/Persons/{Name}/Images/{Type}", "HEAD")] - [Route("/Persons/{Name}/Images/{Type}/{Index}", "HEAD")] - [Route("/Studios/{Name}/Images/{Type}", "HEAD")] - [Route("/Studios/{Name}/Images/{Type}/{Index}", "HEAD")] - ////[Route("/Years/{Year}/Images/{Type}", "HEAD")] - ////[Route("/Years/{Year}/Images/{Type}/{Index}", "HEAD")] - public class GetItemByNameImage : ImageRequest - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "Item name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - } - - /// <summary> - /// Class GetUserImage. - /// </summary> - [Route("/Users/{Id}/Images/{Type}", "GET")] - [Route("/Users/{Id}/Images/{Type}/{Index}", "GET")] - [Route("/Users/{Id}/Images/{Type}", "HEAD")] - [Route("/Users/{Id}/Images/{Type}/{Index}", "HEAD")] - public class GetUserImage : ImageRequest - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class DeleteItemImage. - /// </summary> - [Route("/Items/{Id}/Images/{Type}", "DELETE")] - [Route("/Items/{Id}/Images/{Type}/{Index}", "DELETE")] - [Authenticated(Roles = "admin")] - public class DeleteItemImage : DeleteImageRequest, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - /// <summary> - /// Class DeleteUserImage. - /// </summary> - [Route("/Users/{Id}/Images/{Type}", "DELETE")] - [Route("/Users/{Id}/Images/{Type}/{Index}", "DELETE")] - [Authenticated] - public class DeleteUserImage : DeleteImageRequest, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class PostUserImage. - /// </summary> - [Route("/Users/{Id}/Images/{Type}", "POST")] - [Route("/Users/{Id}/Images/{Type}/{Index}", "POST")] - [Authenticated] - public class PostUserImage : DeleteImageRequest, IRequiresRequestStream, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// The raw Http Request Input Stream. - /// </summary> - /// <value>The request stream.</value> - public Stream RequestStream { get; set; } - } - - /// <summary> - /// Class PostItemImage. - /// </summary> - [Route("/Items/{Id}/Images/{Type}", "POST")] - [Route("/Items/{Id}/Images/{Type}/{Index}", "POST")] - [Authenticated(Roles = "admin")] - public class PostItemImage : DeleteImageRequest, IRequiresRequestStream, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// The raw Http Request Input Stream. - /// </summary> - /// <value>The request stream.</value> - public Stream RequestStream { get; set; } - } - - /// <summary> - /// Class ImageService. - /// </summary> - public class ImageService : BaseApiService - { - private readonly IUserManager _userManager; - - private readonly ILibraryManager _libraryManager; - - private readonly IProviderManager _providerManager; - - private readonly IImageProcessor _imageProcessor; - private readonly IFileSystem _fileSystem; - private readonly IAuthorizationContext _authContext; - - /// <summary> - /// Initializes a new instance of the <see cref="ImageService" /> class. - /// </summary> - public ImageService( - ILogger<ImageService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IProviderManager providerManager, - IImageProcessor imageProcessor, - IFileSystem fileSystem, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _libraryManager = libraryManager; - _providerManager = providerManager; - _imageProcessor = imageProcessor; - _fileSystem = fileSystem; - _authContext = authContext; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetItemImageInfos request) - { - var item = _libraryManager.GetItemById(request.Id); - - var result = GetItemImageInfos(item); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the item image infos. - /// </summary> - /// <param name="item">The item.</param> - /// <returns>Task{List{ImageInfo}}.</returns> - public List<ImageInfo> GetItemImageInfos(BaseItem item) - { - var list = new List<ImageInfo>(); - var itemImages = item.ImageInfos; - - if (itemImages.Length == 0) - { - // short-circuit - return list; - } - - _libraryManager.UpdateImages(item); // this makes sure dimensions and hashes are correct - - foreach (var image in itemImages) - { - if (!item.AllowsMultipleImages(image.Type)) - { - var info = GetImageInfo(item, image, null); - - if (info != null) - { - list.Add(info); - } - } - } - - foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages)) - { - var index = 0; - - // Prevent implicitly captured closure - var currentImageType = imageType; - - foreach (var image in itemImages.Where(i => i.Type == currentImageType)) - { - var info = GetImageInfo(item, image, index); - - if (info != null) - { - list.Add(info); - } - - index++; - } - } - - return list; - } - - private ImageInfo GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex) - { - int? width = null; - int? height = null; - string blurhash = null; - long length = 0; - - try - { - if (info.IsLocalFile) - { - var fileInfo = _fileSystem.GetFileInfo(info.Path); - length = fileInfo.Length; - - blurhash = info.BlurHash; - width = info.Width; - height = info.Height; - - if (width <= 0 || height <= 0) - { - width = null; - height = null; - } - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error getting image information for {Item}", item.Name); - } - - try - { - return new ImageInfo - { - Path = info.Path, - ImageIndex = imageIndex, - ImageType = info.Type, - ImageTag = _imageProcessor.GetImageCacheTag(item, info), - Size = length, - BlurHash = blurhash, - Width = width, - Height = height - }; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error getting image information for {Path}", info.Path); - - return null; - } - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetItemImage request) - { - return GetImage(request, request.Id, null, false); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Head(GetItemImage request) - { - return GetImage(request, request.Id, null, true); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetUserImage request) - { - var item = _userManager.GetUserById(request.Id); - - return GetImage(request, item, false); - } - - public object Head(GetUserImage request) - { - var item = _userManager.GetUserById(request.Id); - - return GetImage(request, item, true); - } - - public object Get(GetItemByNameImage request) - { - var type = GetPathValue(0).ToString(); - - var item = GetItemByName(request.Name, type, _libraryManager, new DtoOptions(false)); - - return GetImage(request, item.Id, item, false); - } - - public object Head(GetItemByNameImage request) - { - var type = GetPathValue(0).ToString(); - - var item = GetItemByName(request.Name, type, _libraryManager, new DtoOptions(false)); - - return GetImage(request, item.Id, item, true); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(PostUserImage request) - { - var id = Guid.Parse(GetPathValue(1)); - - AssertCanUpdateUser(_authContext, _userManager, id, true); - - request.Type = Enum.Parse<ImageType>(GetPathValue(3).ToString(), true); - - var user = _userManager.GetUserById(id); - - return PostImage(user, request.RequestStream, Request.ContentType); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(PostItemImage request) - { - var id = Guid.Parse(GetPathValue(1)); - - request.Type = Enum.Parse<ImageType>(GetPathValue(3).ToString(), true); - - var item = _libraryManager.GetItemById(id); - - return PostImage(item, request.RequestStream, request.Type, Request.ContentType); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Delete(DeleteUserImage request) - { - var userId = request.Id; - AssertCanUpdateUser(_authContext, _userManager, userId, true); - - var user = _userManager.GetUserById(userId); - try - { - File.Delete(user.ProfileImage.Path); - } - catch (IOException e) - { - Logger.LogError(e, "Error deleting user profile image:"); - } - - _userManager.ClearProfileImage(user); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Delete(DeleteItemImage request) - { - var item = _libraryManager.GetItemById(request.Id); - - item.DeleteImage(request.Type, request.Index ?? 0); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(UpdateItemImageIndex request) - { - var item = _libraryManager.GetItemById(request.Id); - - UpdateItemIndex(item, request.Type, request.Index, request.NewIndex); - } - - /// <summary> - /// Updates the index of the item. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="type">The type.</param> - /// <param name="currentIndex">Index of the current.</param> - /// <param name="newIndex">The new index.</param> - /// <returns>Task.</returns> - private void UpdateItemIndex(BaseItem item, ImageType type, int currentIndex, int newIndex) - { - item.SwapImages(type, currentIndex, newIndex); - } - - /// <summary> - /// Gets the image. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="item">The item.</param> - /// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param> - /// <returns>System.Object.</returns> - /// <exception cref="ResourceNotFoundException"></exception> - public Task<object> GetImage(ImageRequest request, Guid itemId, BaseItem item, bool isHeadRequest) - { - if (request.PercentPlayed.HasValue) - { - if (request.PercentPlayed.Value <= 0) - { - request.PercentPlayed = null; - } - else if (request.PercentPlayed.Value >= 100) - { - request.PercentPlayed = null; - request.AddPlayedIndicator = true; - } - } - - if (request.PercentPlayed.HasValue) - { - request.UnplayedCount = null; - } - - if (request.UnplayedCount.HasValue - && request.UnplayedCount.Value <= 0) - { - request.UnplayedCount = null; - } - - if (item == null) - { - item = _libraryManager.GetItemById(itemId); - - if (item == null) - { - throw new ResourceNotFoundException(string.Format("Item {0} not found.", itemId.ToString("N", CultureInfo.InvariantCulture))); - } - } - - var imageInfo = GetImageInfo(request, item); - if (imageInfo == null) - { - throw new ResourceNotFoundException(string.Format("{0} does not have an image of type {1}", item.Name, request.Type)); - } - - bool cropWhitespace; - if (request.CropWhitespace.HasValue) - { - cropWhitespace = request.CropWhitespace.Value; - } - else - { - cropWhitespace = request.Type == ImageType.Logo || request.Type == ImageType.Art; - } - - var outputFormats = GetOutputFormats(request); - - TimeSpan? cacheDuration = null; - - if (!string.IsNullOrEmpty(request.Tag)) - { - cacheDuration = TimeSpan.FromDays(365); - } - - var responseHeaders = new Dictionary<string, string> - { - {"transferMode.dlna.org", "Interactive"}, - {"realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*"} - }; - - return GetImageResult( - item, - itemId, - request, - imageInfo, - cropWhitespace, - outputFormats, - cacheDuration, - responseHeaders, - isHeadRequest); - } - - public Task<object> GetImage(ImageRequest request, User user, bool isHeadRequest) - { - var imageInfo = GetImageInfo(request, user); - - TimeSpan? cacheDuration = null; - - if (!string.IsNullOrEmpty(request.Tag)) - { - cacheDuration = TimeSpan.FromDays(365); - } - - var responseHeaders = new Dictionary<string, string> - { - {"transferMode.dlna.org", "Interactive"}, - {"realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*"} - }; - - var outputFormats = GetOutputFormats(request); - - return GetImageResult(user.Id, - request, - imageInfo, - outputFormats, - cacheDuration, - responseHeaders, - isHeadRequest); - } - - private async Task<object> GetImageResult( - Guid itemId, - ImageRequest request, - ItemImageInfo info, - IReadOnlyCollection<ImageFormat> supportedFormats, - TimeSpan? cacheDuration, - IDictionary<string, string> headers, - bool isHeadRequest) - { - info.Type = ImageType.Profile; - var options = new ImageProcessingOptions - { - CropWhiteSpace = true, - Height = request.Height, - ImageIndex = request.Index ?? 0, - Image = info, - Item = null, // Hack alert - ItemId = itemId, - MaxHeight = request.MaxHeight, - MaxWidth = request.MaxWidth, - Quality = request.Quality ?? 100, - Width = request.Width, - AddPlayedIndicator = request.AddPlayedIndicator, - PercentPlayed = 0, - UnplayedCount = request.UnplayedCount, - Blur = request.Blur, - BackgroundColor = request.BackgroundColor, - ForegroundLayer = request.ForegroundLayer, - SupportedOutputFormats = supportedFormats - }; - - var imageResult = await _imageProcessor.ProcessImage(options).ConfigureAwait(false); - - headers[HeaderNames.Vary] = HeaderNames.Accept; - - return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions - { - CacheDuration = cacheDuration, - ResponseHeaders = headers, - ContentType = imageResult.Item2, - DateLastModified = imageResult.Item3, - IsHeadRequest = isHeadRequest, - Path = imageResult.Item1, - - FileShare = FileShare.Read - - }).ConfigureAwait(false); - } - - private async Task<object> GetImageResult( - BaseItem item, - Guid itemId, - ImageRequest request, - ItemImageInfo image, - bool cropwhitespace, - IReadOnlyCollection<ImageFormat> supportedFormats, - TimeSpan? cacheDuration, - IDictionary<string, string> headers, - bool isHeadRequest) - { - if (!image.IsLocalFile) - { - item ??= _libraryManager.GetItemById(itemId); - image = await _libraryManager.ConvertImageToLocal(item, image, request.Index ?? 0).ConfigureAwait(false); - } - - var options = new ImageProcessingOptions - { - CropWhiteSpace = cropwhitespace, - Height = request.Height, - ImageIndex = request.Index ?? 0, - Image = image, - Item = item, - ItemId = itemId, - MaxHeight = request.MaxHeight, - MaxWidth = request.MaxWidth, - Quality = request.Quality ?? 100, - Width = request.Width, - AddPlayedIndicator = request.AddPlayedIndicator, - PercentPlayed = request.PercentPlayed ?? 0, - UnplayedCount = request.UnplayedCount, - Blur = request.Blur, - BackgroundColor = request.BackgroundColor, - ForegroundLayer = request.ForegroundLayer, - SupportedOutputFormats = supportedFormats - }; - - var imageResult = await _imageProcessor.ProcessImage(options).ConfigureAwait(false); - - headers[HeaderNames.Vary] = HeaderNames.Accept; - - return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions - { - CacheDuration = cacheDuration, - ResponseHeaders = headers, - ContentType = imageResult.Item2, - DateLastModified = imageResult.Item3, - IsHeadRequest = isHeadRequest, - Path = imageResult.Item1, - - FileShare = FileShare.Read - }).ConfigureAwait(false); - } - - private ImageFormat[] GetOutputFormats(ImageRequest request) - { - if (!string.IsNullOrWhiteSpace(request.Format) - && Enum.TryParse(request.Format, true, out ImageFormat format)) - { - return new[] { format }; - } - - return GetClientSupportedFormats(); - } - - private ImageFormat[] GetClientSupportedFormats() - { - var supportedFormats = Request.AcceptTypes ?? Array.Empty<string>(); - if (supportedFormats.Length > 0) - { - for (int i = 0; i < supportedFormats.Length; i++) - { - int index = supportedFormats[i].IndexOf(';'); - if (index != -1) - { - supportedFormats[i] = supportedFormats[i].Substring(0, index); - } - } - } - - var acceptParam = Request.QueryString["accept"]; - - var supportsWebP = SupportsFormat(supportedFormats, acceptParam, "webp", false); - - if (!supportsWebP) - { - var userAgent = Request.UserAgent ?? string.Empty; - if (userAgent.IndexOf("crosswalk", StringComparison.OrdinalIgnoreCase) != -1 && - userAgent.IndexOf("android", StringComparison.OrdinalIgnoreCase) != -1) - { - supportsWebP = true; - } - } - - var formats = new List<ImageFormat>(4); - - if (supportsWebP) - { - formats.Add(ImageFormat.Webp); - } - - formats.Add(ImageFormat.Jpg); - formats.Add(ImageFormat.Png); - - if (SupportsFormat(supportedFormats, acceptParam, "gif", true)) - { - formats.Add(ImageFormat.Gif); - } - - return formats.ToArray(); - } - - private bool SupportsFormat(IEnumerable<string> requestAcceptTypes, string acceptParam, string format, bool acceptAll) - { - var mimeType = "image/" + format; - - if (requestAcceptTypes.Contains(mimeType)) - { - return true; - } - - if (acceptAll && requestAcceptTypes.Contains("*/*")) - { - return true; - } - - return string.Equals(Request.QueryString["accept"], format, StringComparison.OrdinalIgnoreCase); - } - - /// <summary> - /// Gets the image path. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="item">The item.</param> - /// <returns>System.String.</returns> - private static ItemImageInfo GetImageInfo(ImageRequest request, BaseItem item) - { - var index = request.Index ?? 0; - - return item.GetImageInfo(request.Type, index); - } - - private static ItemImageInfo GetImageInfo(ImageRequest request, User user) - { - var info = new ItemImageInfo - { - Path = user.ProfileImage.Path, - Type = ImageType.Primary, - DateModified = user.ProfileImage.LastModified, - }; - - if (request.Width.HasValue) - { - info.Width = request.Width.Value; - } - - if (request.Height.HasValue) - { - info.Height = request.Height.Value; - } - - return info; - } - - /// <summary> - /// Posts the image. - /// </summary> - /// <param name="entity">The entity.</param> - /// <param name="inputStream">The input stream.</param> - /// <param name="imageType">Type of the image.</param> - /// <param name="mimeType">Type of the MIME.</param> - /// <returns>Task.</returns> - public async Task PostImage(BaseItem entity, Stream inputStream, ImageType imageType, string mimeType) - { - var memoryStream = await GetMemoryStream(inputStream); - - // Handle image/png; charset=utf-8 - mimeType = mimeType.Split(';').FirstOrDefault(); - - await _providerManager.SaveImage(entity, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); - - entity.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None); - } - - private static async Task<MemoryStream> GetMemoryStream(Stream inputStream) - { - using var reader = new StreamReader(inputStream); - var text = await reader.ReadToEndAsync().ConfigureAwait(false); - - var bytes = Convert.FromBase64String(text); - return new MemoryStream(bytes) - { - Position = 0 - }; - } - - private async Task PostImage(User user, Stream inputStream, string mimeType) - { - var memoryStream = await GetMemoryStream(inputStream); - - // Handle image/png; charset=utf-8 - mimeType = mimeType.Split(';').FirstOrDefault(); - var userDataPath = Path.Combine(ServerConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); - if (user.ProfileImage != null) - { - _userManager.ClearProfileImage(user); - } - - user.ProfileImage = new Jellyfin.Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType))); - - await _providerManager - .SaveImage(user, memoryStream, mimeType, user.ProfileImage.Path) - .ConfigureAwait(false); - await _userManager.UpdateUserAsync(user); - } - } -} diff --git a/MediaBrowser.Api/Images/RemoteImageService.cs b/MediaBrowser.Api/Images/RemoteImageService.cs deleted file mode 100644 index 86464b4b9..000000000 --- a/MediaBrowser.Api/Images/RemoteImageService.cs +++ /dev/null @@ -1,296 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Images -{ - public class BaseRemoteImageRequest : IReturn<RemoteImageResult> - { - [ApiMember(Name = "Type", Description = "The image type", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public ImageType? Type { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "ProviderName", Description = "Optional. The image provider to use", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ProviderName { get; set; } - - [ApiMember(Name = "IncludeAllLanguages", Description = "Optional.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludeAllLanguages { get; set; } - } - - [Route("/Items/{Id}/RemoteImages", "GET", Summary = "Gets available remote images for an item")] - [Authenticated] - public class GetRemoteImages : BaseRemoteImageRequest - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Items/{Id}/RemoteImages/Providers", "GET", Summary = "Gets available remote image providers for an item")] - [Authenticated] - public class GetRemoteImageProviders : IReturn<List<ImageProviderInfo>> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - public class BaseDownloadRemoteImage : IReturnVoid - { - [ApiMember(Name = "Type", Description = "The image type", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public ImageType Type { get; set; } - - [ApiMember(Name = "ProviderName", Description = "The image provider", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string ProviderName { get; set; } - - [ApiMember(Name = "ImageUrl", Description = "The image url", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string ImageUrl { get; set; } - } - - [Route("/Items/{Id}/RemoteImages/Download", "POST", Summary = "Downloads a remote image for an item")] - [Authenticated(Roles = "Admin")] - public class DownloadRemoteImage : BaseDownloadRemoteImage - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Images/Remote", "GET", Summary = "Gets a remote image")] - public class GetRemoteImage - { - [ApiMember(Name = "ImageUrl", Description = "The image url", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ImageUrl { get; set; } - } - - public class RemoteImageService : BaseApiService - { - private readonly IProviderManager _providerManager; - - private readonly IServerApplicationPaths _appPaths; - private readonly IHttpClient _httpClient; - private readonly IFileSystem _fileSystem; - - private readonly ILibraryManager _libraryManager; - - public RemoteImageService( - ILogger<RemoteImageService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IProviderManager providerManager, - IServerApplicationPaths appPaths, - IHttpClient httpClient, - IFileSystem fileSystem, - ILibraryManager libraryManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _providerManager = providerManager; - _appPaths = appPaths; - _httpClient = httpClient; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - } - - public object Get(GetRemoteImageProviders request) - { - var item = _libraryManager.GetItemById(request.Id); - - var result = GetImageProviders(item); - - return ToOptimizedResult(result); - } - - private List<ImageProviderInfo> GetImageProviders(BaseItem item) - { - return _providerManager.GetRemoteImageProviderInfo(item).ToList(); - } - - public async Task<object> Get(GetRemoteImages request) - { - var item = _libraryManager.GetItemById(request.Id); - - var images = await _providerManager.GetAvailableRemoteImages(item, new RemoteImageQuery(request.ProviderName) - { - IncludeAllLanguages = request.IncludeAllLanguages, - IncludeDisabledProviders = true, - ImageType = request.Type - }, CancellationToken.None).ConfigureAwait(false); - - var imagesList = images.ToArray(); - - var allProviders = _providerManager.GetRemoteImageProviderInfo(item); - - if (request.Type.HasValue) - { - allProviders = allProviders.Where(i => i.SupportedImages.Contains(request.Type.Value)); - } - - var result = new RemoteImageResult - { - TotalRecordCount = imagesList.Length, - Providers = allProviders.Select(i => i.Name) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray() - }; - - if (request.StartIndex.HasValue) - { - imagesList = imagesList.Skip(request.StartIndex.Value) - .ToArray(); - } - - if (request.Limit.HasValue) - { - imagesList = imagesList.Take(request.Limit.Value) - .ToArray(); - } - - result.Images = imagesList; - - return result; - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(DownloadRemoteImage request) - { - var item = _libraryManager.GetItemById(request.Id); - - return DownloadRemoteImage(item, request); - } - - /// <summary> - /// Downloads the remote image. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="request">The request.</param> - /// <returns>Task.</returns> - private async Task DownloadRemoteImage(BaseItem item, BaseDownloadRemoteImage request) - { - await _providerManager.SaveImage(item, request.ImageUrl, request.Type, null, CancellationToken.None).ConfigureAwait(false); - - item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Get(GetRemoteImage request) - { - var urlHash = request.ImageUrl.GetMD5(); - var pointerCachePath = GetFullCachePath(urlHash.ToString()); - - string contentPath; - - try - { - contentPath = File.ReadAllText(pointerCachePath); - - if (File.Exists(contentPath)) - { - return await ResultFactory.GetStaticFileResult(Request, contentPath).ConfigureAwait(false); - } - } - catch (FileNotFoundException) - { - // Means the file isn't cached yet - } - catch (IOException) - { - // Means the file isn't cached yet - } - - await DownloadImage(request.ImageUrl, urlHash, pointerCachePath).ConfigureAwait(false); - - // Read the pointer file again - contentPath = File.ReadAllText(pointerCachePath); - - return await ResultFactory.GetStaticFileResult(Request, contentPath).ConfigureAwait(false); - } - - /// <summary> - /// Downloads the image. - /// </summary> - /// <param name="url">The URL.</param> - /// <param name="urlHash">The URL hash.</param> - /// <param name="pointerCachePath">The pointer cache path.</param> - /// <returns>Task.</returns> - private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath) - { - using var result = await _httpClient.GetResponse(new HttpRequestOptions - { - Url = url, - BufferContent = false - }).ConfigureAwait(false); - var ext = result.ContentType.Split('/')[^1]; - - var fullCachePath = GetFullCachePath(urlHash + "." + ext); - - Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); - var stream = result.Content; - await using (stream.ConfigureAwait(false)) - { - var filestream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); - await using (filestream.ConfigureAwait(false)) - { - await stream.CopyToAsync(filestream).ConfigureAwait(false); - } - } - - Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); - File.WriteAllText(pointerCachePath, fullCachePath); - } - - /// <summary> - /// Gets the full cache path. - /// </summary> - /// <param name="filename">The filename.</param> - /// <returns>System.String.</returns> - private string GetFullCachePath(string filename) - { - return Path.Combine(_appPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); - } - } -} diff --git a/MediaBrowser.Api/ItemLookupService.cs b/MediaBrowser.Api/ItemLookupService.cs deleted file mode 100644 index 862411209..000000000 --- a/MediaBrowser.Api/ItemLookupService.cs +++ /dev/null @@ -1,336 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Items/{Id}/ExternalIdInfos", "GET", Summary = "Gets external id infos for an item")] - [Authenticated(Roles = "Admin")] - public class GetExternalIdInfos : IReturn<List<ExternalIdInfo>> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - } - - [Route("/Items/RemoteSearch/Movie", "POST")] - [Authenticated] - public class GetMovieRemoteSearchResults : RemoteSearchQuery<MovieInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Trailer", "POST")] - [Authenticated] - public class GetTrailerRemoteSearchResults : RemoteSearchQuery<TrailerInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/MusicVideo", "POST")] - [Authenticated] - public class GetMusicVideoRemoteSearchResults : RemoteSearchQuery<MusicVideoInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Series", "POST")] - [Authenticated] - public class GetSeriesRemoteSearchResults : RemoteSearchQuery<SeriesInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/BoxSet", "POST")] - [Authenticated] - public class GetBoxSetRemoteSearchResults : RemoteSearchQuery<BoxSetInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/MusicArtist", "POST")] - [Authenticated] - public class GetMusicArtistRemoteSearchResults : RemoteSearchQuery<ArtistInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/MusicAlbum", "POST")] - [Authenticated] - public class GetMusicAlbumRemoteSearchResults : RemoteSearchQuery<AlbumInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Person", "POST")] - [Authenticated(Roles = "Admin")] - public class GetPersonRemoteSearchResults : RemoteSearchQuery<PersonLookupInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Book", "POST")] - [Authenticated] - public class GetBookRemoteSearchResults : RemoteSearchQuery<BookInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Image", "GET", Summary = "Gets a remote image")] - public class GetRemoteSearchImage - { - [ApiMember(Name = "ImageUrl", Description = "The image url", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ImageUrl { get; set; } - - [ApiMember(Name = "ProviderName", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ProviderName { get; set; } - } - - [Route("/Items/RemoteSearch/Apply/{Id}", "POST", Summary = "Applies search criteria to an item and refreshes metadata")] - [Authenticated(Roles = "Admin")] - public class ApplySearchCriteria : RemoteSearchResult, IReturnVoid - { - [ApiMember(Name = "Id", Description = "The item id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "ReplaceAllImages", Description = "Whether or not to replace all images", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public bool ReplaceAllImages { get; set; } - - public ApplySearchCriteria() - { - ReplaceAllImages = true; - } - } - - public class ItemLookupService : BaseApiService - { - private readonly IProviderManager _providerManager; - private readonly IServerApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - private readonly ILibraryManager _libraryManager; - private readonly IJsonSerializer _json; - - public ItemLookupService( - ILogger<ItemLookupService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IProviderManager providerManager, - IFileSystem fileSystem, - ILibraryManager libraryManager, - IJsonSerializer json) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _providerManager = providerManager; - _appPaths = serverConfigurationManager.ApplicationPaths; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - _json = json; - } - - public object Get(GetExternalIdInfos request) - { - var item = _libraryManager.GetItemById(request.Id); - - var infos = _providerManager.GetExternalIdInfos(item).ToList(); - - return ToOptimizedResult(infos); - } - - public async Task<object> Post(GetTrailerRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetBookRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetMovieRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetSeriesRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetBoxSetRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetMusicVideoRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetPersonRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetMusicAlbumRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetMusicArtistRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public Task<object> Get(GetRemoteSearchImage request) - { - return GetRemoteImage(request); - } - - public Task Post(ApplySearchCriteria request) - { - var item = _libraryManager.GetItemById(new Guid(request.Id)); - - // foreach (var key in request.ProviderIds) - //{ - // var value = key.Value; - - // if (!string.IsNullOrWhiteSpace(value)) - // { - // item.SetProviderId(key.Key, value); - // } - //} - Logger.LogInformation("Setting provider id's to item {0}-{1}: {2}", item.Id, item.Name, _json.SerializeToString(request.ProviderIds)); - - // Since the refresh process won't erase provider Ids, we need to set this explicitly now. - item.ProviderIds = request.ProviderIds; - // item.ProductionYear = request.ProductionYear; - // item.Name = request.Name; - - return _providerManager.RefreshFullItem( - item, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true, - ReplaceAllImages = request.ReplaceAllImages, - SearchResult = request - }, - CancellationToken.None); - } - - /// <summary> - /// Gets the remote image. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{System.Object}.</returns> - private async Task<object> GetRemoteImage(GetRemoteSearchImage request) - { - var urlHash = request.ImageUrl.GetMD5(); - var pointerCachePath = GetFullCachePath(urlHash.ToString()); - - string contentPath; - - try - { - contentPath = File.ReadAllText(pointerCachePath); - - if (File.Exists(contentPath)) - { - return await ResultFactory.GetStaticFileResult(Request, contentPath).ConfigureAwait(false); - } - } - catch (FileNotFoundException) - { - // Means the file isn't cached yet - } - catch (IOException) - { - // Means the file isn't cached yet - } - - await DownloadImage(request.ProviderName, request.ImageUrl, urlHash, pointerCachePath).ConfigureAwait(false); - - // Read the pointer file again - contentPath = File.ReadAllText(pointerCachePath); - - return await ResultFactory.GetStaticFileResult(Request, contentPath).ConfigureAwait(false); - } - - /// <summary> - /// Downloads the image. - /// </summary> - /// <param name="providerName">Name of the provider.</param> - /// <param name="url">The URL.</param> - /// <param name="urlHash">The URL hash.</param> - /// <param name="pointerCachePath">The pointer cache path.</param> - /// <returns>Task.</returns> - private async Task DownloadImage(string providerName, string url, Guid urlHash, string pointerCachePath) - { - var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false); - - var ext = result.ContentType.Split('/')[^1]; - - var fullCachePath = GetFullCachePath(urlHash + "." + ext); - - Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); - var stream = result.Content; - - await using (stream.ConfigureAwait(false)) - { - var fileStream = new FileStream( - fullCachePath, - FileMode.Create, - FileAccess.Write, - FileShare.Read, - IODefaults.FileStreamBufferSize, - true); - await using (fileStream.ConfigureAwait(false)) - { - await stream.CopyToAsync(fileStream).ConfigureAwait(false); - } - } - - Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); - File.WriteAllText(pointerCachePath, fullCachePath); - } - - /// <summary> - /// Gets the full cache path. - /// </summary> - /// <param name="filename">The filename.</param> - /// <returns>System.String.</returns> - private string GetFullCachePath(string filename) - => Path.Combine(_appPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); - } -} diff --git a/MediaBrowser.Api/ItemRefreshService.cs b/MediaBrowser.Api/ItemRefreshService.cs deleted file mode 100644 index 5e86f04a8..000000000 --- a/MediaBrowser.Api/ItemRefreshService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - public class BaseRefreshRequest : IReturnVoid - { - [ApiMember(Name = "MetadataRefreshMode", Description = "Specifies the metadata refresh mode", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public MetadataRefreshMode MetadataRefreshMode { get; set; } - - [ApiMember(Name = "ImageRefreshMode", Description = "Specifies the image refresh mode", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public MetadataRefreshMode ImageRefreshMode { get; set; } - - [ApiMember(Name = "ReplaceAllMetadata", Description = "Determines if metadata should be replaced. Only applicable if mode is FullRefresh", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public bool ReplaceAllMetadata { get; set; } - - [ApiMember(Name = "ReplaceAllImages", Description = "Determines if images should be replaced. Only applicable if mode is FullRefresh", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public bool ReplaceAllImages { get; set; } - } - - [Route("/Items/{Id}/Refresh", "POST", Summary = "Refreshes metadata for an item")] - public class RefreshItem : BaseRefreshRequest - { - [ApiMember(Name = "Recursive", Description = "Indicates if the refresh should occur recursively.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool Recursive { get; set; } - - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - [Authenticated] - public class ItemRefreshService : BaseApiService - { - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; - - public ItemRefreshService( - ILogger<ItemRefreshService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ILibraryManager libraryManager, - IProviderManager providerManager, - IFileSystem fileSystem) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _libraryManager = libraryManager; - _providerManager = providerManager; - _fileSystem = fileSystem; - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(RefreshItem request) - { - var item = _libraryManager.GetItemById(request.Id); - - var options = GetRefreshOptions(request); - - _providerManager.QueueRefresh(item.Id, options, RefreshPriority.High); - } - - private MetadataRefreshOptions GetRefreshOptions(RefreshItem request) - { - return new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = request.MetadataRefreshMode, - ImageRefreshMode = request.ImageRefreshMode, - ReplaceAllImages = request.ReplaceAllImages, - ReplaceAllMetadata = request.ReplaceAllMetadata, - ForceSave = request.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || request.ImageRefreshMode == MetadataRefreshMode.FullRefresh || request.ReplaceAllImages || request.ReplaceAllMetadata, - IsAutomated = false - }; - } - } -} diff --git a/MediaBrowser.Api/Library/LibraryService.cs b/MediaBrowser.Api/Library/LibraryService.cs deleted file mode 100644 index 6555864dc..000000000 --- a/MediaBrowser.Api/Library/LibraryService.cs +++ /dev/null @@ -1,1124 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using MediaBrowser.Api.Movies; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; -using Book = MediaBrowser.Controller.Entities.Book; -using Episode = MediaBrowser.Controller.Entities.TV.Episode; -using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; -using Movie = MediaBrowser.Controller.Entities.Movies.Movie; -using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; -using Series = MediaBrowser.Controller.Entities.TV.Series; - -namespace MediaBrowser.Api.Library -{ - [Route("/Items/{Id}/File", "GET", Summary = "Gets the original file of an item")] - [Authenticated] - public class GetFile - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - /// <summary> - /// Class GetCriticReviews. - /// </summary> - [Route("/Items/{Id}/CriticReviews", "GET", Summary = "Gets critic reviews for an item")] - [Authenticated] - public class GetCriticReviews : IReturn<QueryResult<BaseItemDto>> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - } - - /// <summary> - /// Class GetThemeSongs. - /// </summary> - [Route("/Items/{Id}/ThemeSongs", "GET", Summary = "Gets theme songs for an item")] - [Authenticated] - public class GetThemeSongs : IReturn<ThemeMediaResult> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "InheritFromParent", Description = "Determines whether or not parent items should be searched for theme media.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool InheritFromParent { get; set; } - } - - /// <summary> - /// Class GetThemeVideos. - /// </summary> - [Route("/Items/{Id}/ThemeVideos", "GET", Summary = "Gets theme videos for an item")] - [Authenticated] - public class GetThemeVideos : IReturn<ThemeMediaResult> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "InheritFromParent", Description = "Determines whether or not parent items should be searched for theme media.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool InheritFromParent { get; set; } - } - - /// <summary> - /// Class GetThemeVideos. - /// </summary> - [Route("/Items/{Id}/ThemeMedia", "GET", Summary = "Gets theme videos and songs for an item")] - [Authenticated] - public class GetThemeMedia : IReturn<AllThemeMediaResult> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "InheritFromParent", Description = "Determines whether or not parent items should be searched for theme media.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool InheritFromParent { get; set; } - } - - [Route("/Library/Refresh", "POST", Summary = "Starts a library scan")] - [Authenticated(Roles = "Admin")] - public class RefreshLibrary : IReturnVoid - { - } - - [Route("/Items/{Id}", "DELETE", Summary = "Deletes an item from the library and file system")] - [Authenticated] - public class DeleteItem : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/Items", "DELETE", Summary = "Deletes an item from the library and file system")] - [Authenticated] - public class DeleteItems : IReturnVoid - { - [ApiMember(Name = "Ids", Description = "Ids", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string Ids { get; set; } - } - - [Route("/Items/Counts", "GET")] - [Authenticated] - public class GetItemCounts : IReturn<ItemCounts> - { - [ApiMember(Name = "UserId", Description = "Optional. Get counts from a specific user's library.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - [ApiMember(Name = "IsFavorite", Description = "Optional. Get counts of favorite items", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsFavorite { get; set; } - } - - [Route("/Items/{Id}/Ancestors", "GET", Summary = "Gets all parents of an item")] - [Authenticated] - public class GetAncestors : IReturn<BaseItemDto[]> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - /// <summary> - /// Class GetPhyscialPaths. - /// </summary> - [Route("/Library/PhysicalPaths", "GET", Summary = "Gets a list of physical paths from virtual folders")] - [Authenticated(Roles = "Admin")] - public class GetPhyscialPaths : IReturn<List<string>> - { - } - - [Route("/Library/MediaFolders", "GET", Summary = "Gets all user media folders.")] - [Authenticated] - public class GetMediaFolders : IReturn<QueryResult<BaseItemDto>> - { - [ApiMember(Name = "IsHidden", Description = "Optional. Filter by folders that are marked hidden, or not.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? IsHidden { get; set; } - } - - [Route("/Library/Series/Added", "POST", Summary = "Reports that new episodes of a series have been added by an external source")] - [Route("/Library/Series/Updated", "POST", Summary = "Reports that new episodes of a series have been added by an external source")] - [Authenticated] - public class PostUpdatedSeries : IReturnVoid - { - [ApiMember(Name = "TvdbId", Description = "Tvdb Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "POST")] - public string TvdbId { get; set; } - } - - [Route("/Library/Movies/Added", "POST", Summary = "Reports that new movies have been added by an external source")] - [Route("/Library/Movies/Updated", "POST", Summary = "Reports that new movies have been added by an external source")] - [Authenticated] - public class PostUpdatedMovies : IReturnVoid - { - [ApiMember(Name = "TmdbId", Description = "Tmdb Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "POST")] - public string TmdbId { get; set; } - [ApiMember(Name = "ImdbId", Description = "Imdb Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "POST")] - public string ImdbId { get; set; } - } - - public class MediaUpdateInfo - { - public string Path { get; set; } - - // Created, Modified, Deleted - public string UpdateType { get; set; } - } - - [Route("/Library/Media/Updated", "POST", Summary = "Reports that new movies have been added by an external source")] - [Authenticated] - public class PostUpdatedMedia : IReturnVoid - { - [ApiMember(Name = "Updates", Description = "A list of updated media paths", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public List<MediaUpdateInfo> Updates { get; set; } - } - - [Route("/Items/{Id}/Download", "GET", Summary = "Downloads item media")] - [Authenticated(Roles = "download")] - public class GetDownload - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Artists/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")] - [Route("/Items/{Id}/Similar", "GET", Summary = "Gets similar items")] - [Route("/Albums/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")] - [Route("/Shows/{Id}/Similar", "GET", Summary = "Finds tv shows similar to a given one.")] - [Route("/Movies/{Id}/Similar", "GET", Summary = "Finds movies and trailers similar to a given movie.")] - [Route("/Trailers/{Id}/Similar", "GET", Summary = "Finds movies and trailers similar to a given trailer.")] - [Authenticated] - public class GetSimilarItems : BaseGetSimilarItemsFromItem - { - } - - [Route("/Libraries/AvailableOptions", "GET")] - [Authenticated(AllowBeforeStartupWizard = true)] - public class GetLibraryOptionsInfo : IReturn<LibraryOptionsResult> - { - public string LibraryContentType { get; set; } - - public bool IsNewLibrary { get; set; } - } - - public class LibraryOptionInfo - { - public string Name { get; set; } - - public bool DefaultEnabled { get; set; } - } - - public class LibraryOptionsResult - { - public LibraryOptionInfo[] MetadataSavers { get; set; } - - public LibraryOptionInfo[] MetadataReaders { get; set; } - - public LibraryOptionInfo[] SubtitleFetchers { get; set; } - - public LibraryTypeOptions[] TypeOptions { get; set; } - } - - public class LibraryTypeOptions - { - public string Type { get; set; } - - public LibraryOptionInfo[] MetadataFetchers { get; set; } - - public LibraryOptionInfo[] ImageFetchers { get; set; } - - public ImageType[] SupportedImageTypes { get; set; } - - public ImageOption[] DefaultImageOptions { get; set; } - } - - /// <summary> - /// Class LibraryService. - /// </summary> - public class LibraryService : BaseApiService - { - private readonly IProviderManager _providerManager; - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - private readonly IActivityManager _activityManager; - private readonly ILocalizationManager _localization; - private readonly ILibraryMonitor _libraryMonitor; - - private readonly ILogger<MoviesService> _moviesServiceLogger; - - /// <summary> - /// Initializes a new instance of the <see cref="LibraryService" /> class. - /// </summary> - public LibraryService( - ILogger<LibraryService> logger, - ILogger<MoviesService> moviesServiceLogger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IProviderManager providerManager, - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IAuthorizationContext authContext, - IActivityManager activityManager, - ILocalizationManager localization, - ILibraryMonitor libraryMonitor) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _providerManager = providerManager; - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - _authContext = authContext; - _activityManager = activityManager; - _localization = localization; - _libraryMonitor = libraryMonitor; - _moviesServiceLogger = moviesServiceLogger; - } - - // Content Types available for each Library - private string[] GetRepresentativeItemTypes(string contentType) - { - return contentType switch - { - CollectionType.BoxSets => new[] {"BoxSet"}, - CollectionType.Playlists => new[] {"Playlist"}, - CollectionType.Movies => new[] {"Movie"}, - CollectionType.TvShows => new[] {"Series", "Season", "Episode"}, - CollectionType.Books => new[] {"Book"}, - CollectionType.Music => new[] {"MusicArtist", "MusicAlbum", "Audio", "MusicVideo"}, - CollectionType.HomeVideos => new[] {"Video", "Photo"}, - CollectionType.Photos => new[] {"Video", "Photo"}, - CollectionType.MusicVideos => new[] {"MusicVideo"}, - _ => new[] {"Series", "Season", "Episode", "Movie"} - }; - } - - private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) - { - if (isNewLibrary) - { - return false; - } - - var metadataOptions = ServerConfigurationManager.Configuration.MetadataOptions - .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) - .ToArray(); - - if (metadataOptions.Length == 0) - { - return true; - } - - return metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparer.OrdinalIgnoreCase)); - } - - private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary) - { - if (isNewLibrary) - { - if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) - { - return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) - || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) - || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase)); - } - - return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase); - } - - var metadataOptions = ServerConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - return metadataOptions.Length == 0 - || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); - } - - private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) - { - if (isNewLibrary) - { - if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) - { - return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); - } - - return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); - } - - var metadataOptions = ServerConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - if (metadataOptions.Length == 0) - { - return true; - } - - return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); - } - - public object Get(GetLibraryOptionsInfo request) - { - var result = new LibraryOptionsResult(); - - var types = GetRepresentativeItemTypes(request.LibraryContentType); - var isNewLibrary = request.IsNewLibrary; - var typesList = types.ToList(); - - var plugins = _providerManager.GetAllMetadataPlugins() - .Where(i => types.Contains(i.ItemType, StringComparer.OrdinalIgnoreCase)) - .OrderBy(i => typesList.IndexOf(i.ItemType)) - .ToList(); - - result.MetadataSavers = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary) - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(); - - result.MetadataReaders = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = true - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(); - - result.SubtitleFetchers = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = true - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(); - - var typeOptions = new List<LibraryTypeOptions>(); - - foreach (var type in types) - { - TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); - - typeOptions.Add(new LibraryTypeOptions - { - Type = type, - - MetadataFetchers = plugins - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary) - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(), - - ImageFetchers = plugins - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary) - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(), - - SupportedImageTypes = plugins - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>()) - .Distinct() - .ToArray(), - - DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>() - }); - } - - result.TypeOptions = typeOptions.ToArray(); - - return result; - } - - public object Get(GetSimilarItems request) - { - var item = string.IsNullOrEmpty(request.Id) ? - (!request.UserId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : - _libraryManager.RootFolder) : _libraryManager.GetItemById(request.Id); - - var program = item as IHasProgramAttributes; - - if (item is Movie || (program != null && program.IsMovie) || item is Trailer) - { - return new MoviesService( - _moviesServiceLogger, - ServerConfigurationManager, - ResultFactory, - _userManager, - _libraryManager, - _dtoService, - _authContext) - { - Request = Request, - }.GetSimilarItemsResult(request); - } - - if (program != null && program.IsSeries) - { - return GetSimilarItemsResult(request, new[] { typeof(Series).Name }); - } - - if (item is Episode || (item is IItemByName && !(item is MusicArtist))) - { - return new QueryResult<BaseItemDto>(); - } - - return GetSimilarItemsResult(request, new[] { item.GetType().Name }); - } - - private QueryResult<BaseItemDto> GetSimilarItemsResult(BaseGetSimilarItemsFromItem request, string[] includeItemTypes) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var item = string.IsNullOrEmpty(request.Id) ? - (!request.UserId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : - _libraryManager.RootFolder) : _libraryManager.GetItemById(request.Id); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var query = new InternalItemsQuery(user) - { - Limit = request.Limit, - IncludeItemTypes = includeItemTypes, - SimilarTo = item, - DtoOptions = dtoOptions, - EnableTotalRecordCount = false - }; - - // ExcludeArtistIds - if (!string.IsNullOrEmpty(request.ExcludeArtistIds)) - { - query.ExcludeArtistIds = GetGuids(request.ExcludeArtistIds); - } - - List<BaseItem> itemsResult; - - if (item is MusicArtist) - { - query.IncludeItemTypes = Array.Empty<string>(); - - 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<BaseItemDto> - { - Items = returnList, - TotalRecordCount = itemsResult.Count - }; - - return result; - } - - public object Get(GetMediaFolders request) - { - var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); - - if (request.IsHidden.HasValue) - { - var val = request.IsHidden.Value; - - items = items.Where(i => i.IsHidden == val).ToList(); - } - - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = new QueryResult<BaseItemDto> - { - TotalRecordCount = items.Count, - - Items = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions)).ToArray() - }; - - return result; - } - - public void Post(PostUpdatedSeries request) - { - var series = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { typeof(Series).Name }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }).Where(i => string.Equals(request.TvdbId, i.GetProviderId(MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); - - foreach (var item in series) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } - } - - public void Post(PostUpdatedMedia request) - { - if (request.Updates != null) - { - foreach (var item in request.Updates) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } - } - } - - public void Post(PostUpdatedMovies request) - { - var movies = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { typeof(Movie).Name }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }); - - if (!string.IsNullOrWhiteSpace(request.ImdbId)) - { - movies = movies.Where(i => string.Equals(request.ImdbId, i.GetProviderId(MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList(); - } - else if (!string.IsNullOrWhiteSpace(request.TmdbId)) - { - movies = movies.Where(i => string.Equals(request.TmdbId, i.GetProviderId(MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList(); - } - else - { - movies = new List<BaseItem>(); - } - - foreach (var item in movies) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } - } - - public Task<object> Get(GetDownload request) - { - var item = _libraryManager.GetItemById(request.Id); - var auth = _authContext.GetAuthorizationInfo(Request); - - var user = auth.User; - - if (user != null) - { - if (!item.CanDownload(user)) - { - throw new ArgumentException("Item does not support downloading"); - } - } - else - { - if (!item.CanDownload()) - { - throw new ArgumentException("Item does not support downloading"); - } - } - - var headers = new Dictionary<string, string>(); - - if (user != null) - { - LogDownload(item, user, auth); - } - - var path = item.Path; - - // Quotes are valid in linux. They'll possibly cause issues here - var filename = (Path.GetFileName(path) ?? string.Empty).Replace("\"", string.Empty); - if (!string.IsNullOrWhiteSpace(filename)) - { - // Kestrel doesn't support non-ASCII characters in headers - if (Regex.IsMatch(filename, @"[^\p{IsBasicLatin}]")) - { - // Manually encoding non-ASCII characters, following https://tools.ietf.org/html/rfc5987#section-3.2.2 - headers[HeaderNames.ContentDisposition] = "attachment; filename*=UTF-8''" + WebUtility.UrlEncode(filename); - } - else - { - headers[HeaderNames.ContentDisposition] = "attachment; filename=\"" + filename + "\""; - } - } - - return ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions - { - Path = path, - ResponseHeaders = headers - }); - } - - private void LogDownload(BaseItem item, User user, AuthorizationInfo auth) - { - try - { - _activityManager.Create(new ActivityLog( - string.Format(_localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), - "UserDownloadingContent", - auth.UserId) - { - ShortOverview = string.Format(_localization.GetLocalizedString("AppDeviceValues"), auth.Client, auth.Device), - }); - } - catch - { - // Logged at lower levels - } - } - - public Task<object> Get(GetFile request) - { - var item = _libraryManager.GetItemById(request.Id); - - return ResultFactory.GetStaticFileResult(Request, item.Path); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetPhyscialPaths request) - { - var result = _libraryManager.RootFolder.Children - .SelectMany(c => c.PhysicalLocations) - .ToList(); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetAncestors request) - { - var result = GetAncestors(request); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the ancestors. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{BaseItemDto[]}.</returns> - public List<BaseItemDto> GetAncestors(GetAncestors request) - { - var item = _libraryManager.GetItemById(request.Id); - - var baseItemDtos = new List<BaseItemDto>(); - - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var dtoOptions = GetDtoOptions(_authContext, request); - - BaseItem parent = item.GetParent(); - - while (parent != null) - { - if (user != null) - { - parent = TranslateParentItem(parent, user); - } - - baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - - parent = parent.GetParent(); - } - - return baseItemDtos; - } - - private BaseItem TranslateParentItem(BaseItem item, User user) - { - return item.GetParent() is AggregateFolder - ? _libraryManager.GetUserRootFolder().GetChildren(user, true) - .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path)) - : item; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetCriticReviews request) - { - return new QueryResult<BaseItemDto>(); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetItemCounts request) - { - var user = request.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(request.UserId); - - var counts = new ItemCounts - { - AlbumCount = GetCount(typeof(MusicAlbum), user, request), - EpisodeCount = GetCount(typeof(Episode), user, request), - MovieCount = GetCount(typeof(Movie), user, request), - SeriesCount = GetCount(typeof(Series), user, request), - SongCount = GetCount(typeof(Audio), user, request), - MusicVideoCount = GetCount(typeof(MusicVideo), user, request), - BoxSetCount = GetCount(typeof(BoxSet), user, request), - BookCount = GetCount(typeof(Book), user, request) - }; - - return ToOptimizedResult(counts); - } - - private int GetCount(Type type, User user, GetItemCounts request) - { - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { type.Name }, - Limit = 0, - Recursive = true, - IsVirtualItem = false, - IsFavorite = request.IsFavorite, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }; - - return _libraryManager.GetItemsResult(query).TotalRecordCount; - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public async Task Post(RefreshLibrary request) - { - try - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error refreshing library"); - } - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Delete(DeleteItems request) - { - var ids = string.IsNullOrWhiteSpace(request.Ids) - ? Array.Empty<string>() - : request.Ids.Split(','); - - foreach (var i in ids) - { - var item = _libraryManager.GetItemById(i); - var auth = _authContext.GetAuthorizationInfo(Request); - var user = auth.User; - - if (!item.CanDelete(user)) - { - if (ids.Length > 1) - { - throw new SecurityException("Unauthorized access"); - } - - continue; - } - - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = true - }, true); - } - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Delete(DeleteItem request) - { - Delete(new DeleteItems - { - Ids = request.Id - }); - } - - public object Get(GetThemeMedia request) - { - var themeSongs = GetThemeSongs(new GetThemeSongs - { - InheritFromParent = request.InheritFromParent, - Id = request.Id, - UserId = request.UserId - - }); - - var themeVideos = GetThemeVideos(new GetThemeVideos - { - InheritFromParent = request.InheritFromParent, - Id = request.Id, - UserId = request.UserId - - }); - - return ToOptimizedResult(new AllThemeMediaResult - { - ThemeSongsResult = themeSongs, - ThemeVideosResult = themeVideos, - - SoundtrackSongsResult = new ThemeMediaResult() - }); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetThemeSongs request) - { - var result = GetThemeSongs(request); - - return ToOptimizedResult(result); - } - - private ThemeMediaResult GetThemeSongs(GetThemeSongs request) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var item = string.IsNullOrEmpty(request.Id) - ? (!request.UserId.Equals(Guid.Empty) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.RootFolder) - : _libraryManager.GetItemById(request.Id); - - if (item == null) - { - throw new ResourceNotFoundException("Item not found."); - } - - IEnumerable<BaseItem> themeItems; - - while (true) - { - themeItems = item.GetThemeSongs(); - - if (themeItems.Any() || !request.InheritFromParent) - { - break; - } - - var parent = item.GetParent(); - if (parent == null) - { - break; - } - - item = parent; - } - - var dtoOptions = GetDtoOptions(_authContext, request); - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); - - return new ThemeMediaResult - { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetThemeVideos request) - { - return ToOptimizedResult(GetThemeVideos(request)); - } - - public ThemeMediaResult GetThemeVideos(GetThemeVideos request) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var item = string.IsNullOrEmpty(request.Id) - ? (!request.UserId.Equals(Guid.Empty) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.RootFolder) - : _libraryManager.GetItemById(request.Id); - - if (item == null) - { - throw new ResourceNotFoundException("Item not found."); - } - - IEnumerable<BaseItem> themeItems; - - while (true) - { - themeItems = item.GetThemeVideos(); - - if (themeItems.Any() || !request.InheritFromParent) - { - break; - } - - var parent = item.GetParent(); - if (parent == null) - { - break; - } - - item = parent; - } - - var dtoOptions = GetDtoOptions(_authContext, request); - - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); - - return new ThemeMediaResult - { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - } -} diff --git a/MediaBrowser.Api/Library/LibraryStructureService.cs b/MediaBrowser.Api/Library/LibraryStructureService.cs deleted file mode 100644 index b69550ed1..000000000 --- a/MediaBrowser.Api/Library/LibraryStructureService.cs +++ /dev/null @@ -1,412 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Progress; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Library -{ - /// <summary> - /// Class GetDefaultVirtualFolders. - /// </summary> - [Route("/Library/VirtualFolders", "GET")] - public class GetVirtualFolders : IReturn<List<VirtualFolderInfo>> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - public string UserId { get; set; } - } - - [Route("/Library/VirtualFolders", "POST")] - public class AddVirtualFolder : IReturnVoid - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets the type of the collection. - /// </summary> - /// <value>The type of the collection.</value> - public string CollectionType { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [refresh library]. - /// </summary> - /// <value><c>true</c> if [refresh library]; otherwise, <c>false</c>.</value> - public bool RefreshLibrary { get; set; } - - /// <summary> - /// Gets or sets the path. - /// </summary> - /// <value>The path.</value> - public string[] Paths { get; set; } - - public LibraryOptions LibraryOptions { get; set; } - } - - [Route("/Library/VirtualFolders", "DELETE")] - public class RemoveVirtualFolder : IReturnVoid - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [refresh library]. - /// </summary> - /// <value><c>true</c> if [refresh library]; otherwise, <c>false</c>.</value> - public bool RefreshLibrary { get; set; } - } - - [Route("/Library/VirtualFolders/Name", "POST")] - public class RenameVirtualFolder : IReturnVoid - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string NewName { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [refresh library]. - /// </summary> - /// <value><c>true</c> if [refresh library]; otherwise, <c>false</c>.</value> - public bool RefreshLibrary { get; set; } - } - - [Route("/Library/VirtualFolders/Paths", "POST")] - public class AddMediaPath : IReturnVoid - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Path { get; set; } - - public MediaPathInfo PathInfo { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [refresh library]. - /// </summary> - /// <value><c>true</c> if [refresh library]; otherwise, <c>false</c>.</value> - public bool RefreshLibrary { get; set; } - } - - [Route("/Library/VirtualFolders/Paths/Update", "POST")] - public class UpdateMediaPath : IReturnVoid - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - public MediaPathInfo PathInfo { get; set; } - } - - [Route("/Library/VirtualFolders/Paths", "DELETE")] - public class RemoveMediaPath : IReturnVoid - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Path { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [refresh library]. - /// </summary> - /// <value><c>true</c> if [refresh library]; otherwise, <c>false</c>.</value> - public bool RefreshLibrary { get; set; } - } - - [Route("/Library/VirtualFolders/LibraryOptions", "POST")] - public class UpdateLibraryOptions : IReturnVoid - { - public string Id { get; set; } - - public LibraryOptions LibraryOptions { get; set; } - } - - /// <summary> - /// Class LibraryStructureService. - /// </summary> - [Authenticated(Roles = "Admin", AllowBeforeStartupWizard = true)] - public class LibraryStructureService : BaseApiService - { - /// <summary> - /// The _app paths. - /// </summary> - private readonly IServerApplicationPaths _appPaths; - - /// <summary> - /// The _library manager. - /// </summary> - private readonly ILibraryManager _libraryManager; - private readonly ILibraryMonitor _libraryMonitor; - - - /// <summary> - /// Initializes a new instance of the <see cref="LibraryStructureService" /> class. - /// </summary> - public LibraryStructureService( - ILogger<LibraryStructureService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _appPaths = serverConfigurationManager.ApplicationPaths; - _libraryManager = libraryManager; - _libraryMonitor = libraryMonitor; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetVirtualFolders request) - { - var result = _libraryManager.GetVirtualFolders(true); - - return ToOptimizedResult(result); - } - - public void Post(UpdateLibraryOptions request) - { - var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id); - - collectionFolder.UpdateLibraryOptions(request.LibraryOptions); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(AddVirtualFolder request) - { - var libraryOptions = request.LibraryOptions ?? new LibraryOptions(); - - if (request.Paths != null && request.Paths.Length > 0) - { - libraryOptions.PathInfos = request.Paths.Select(i => new MediaPathInfo { Path = i }).ToArray(); - } - - return _libraryManager.AddVirtualFolder(request.Name, request.CollectionType, libraryOptions, request.RefreshLibrary); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(RenameVirtualFolder request) - { - if (string.IsNullOrWhiteSpace(request.Name)) - { - throw new ArgumentNullException(nameof(request)); - } - - if (string.IsNullOrWhiteSpace(request.NewName)) - { - throw new ArgumentNullException(nameof(request)); - } - - var rootFolderPath = _appPaths.DefaultUserViewsPath; - - var currentPath = Path.Combine(rootFolderPath, request.Name); - var newPath = Path.Combine(rootFolderPath, request.NewName); - - if (!Directory.Exists(currentPath)) - { - throw new FileNotFoundException("The media collection does not exist"); - } - - if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath)) - { - throw new ArgumentException("Media library already exists at " + newPath + "."); - } - - _libraryMonitor.Stop(); - - try - { - // Changing capitalization. Handle windows case insensitivity - if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase)) - { - var tempPath = Path.Combine(rootFolderPath, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); - Directory.Move(currentPath, tempPath); - currentPath = tempPath; - } - - Directory.Move(currentPath, newPath); - } - finally - { - CollectionFolder.OnCollectionFolderChange(); - - Task.Run(() => - { - // No need to start if scanning the library because it will handle it - if (request.RefreshLibrary) - { - _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - var task = Task.Delay(1000); - // Have to block here to allow exceptions to bubble - Task.WaitAll(task); - - _libraryMonitor.Start(); - } - }); - } - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Delete(RemoveVirtualFolder request) - { - return _libraryManager.RemoveVirtualFolder(request.Name, request.RefreshLibrary); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(AddMediaPath request) - { - if (string.IsNullOrWhiteSpace(request.Name)) - { - throw new ArgumentNullException(nameof(request)); - } - - _libraryMonitor.Stop(); - - try - { - var mediaPath = request.PathInfo ?? new MediaPathInfo - { - Path = request.Path - }; - - _libraryManager.AddMediaPath(request.Name, mediaPath); - } - finally - { - Task.Run(() => - { - // No need to start if scanning the library because it will handle it - if (request.RefreshLibrary) - { - _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - var task = Task.Delay(1000); - // Have to block here to allow exceptions to bubble - Task.WaitAll(task); - - _libraryMonitor.Start(); - } - }); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(UpdateMediaPath request) - { - if (string.IsNullOrWhiteSpace(request.Name)) - { - throw new ArgumentNullException(nameof(request)); - } - - _libraryManager.UpdateMediaPath(request.Name, request.PathInfo); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Delete(RemoveMediaPath request) - { - if (string.IsNullOrWhiteSpace(request.Name)) - { - throw new ArgumentNullException(nameof(request)); - } - - _libraryMonitor.Stop(); - - try - { - _libraryManager.RemoveMediaPath(request.Name, request.Path); - } - finally - { - Task.Run(() => - { - // No need to start if scanning the library because it will handle it - if (request.RefreshLibrary) - { - _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - var task = Task.Delay(1000); - // Have to block here to allow exceptions to bubble - Task.WaitAll(task); - - _libraryMonitor.Start(); - } - }); - } - } - } -} diff --git a/MediaBrowser.Api/LiveTv/LiveTvService.cs b/MediaBrowser.Api/LiveTv/LiveTvService.cs deleted file mode 100644 index 830372dd8..000000000 --- a/MediaBrowser.Api/LiveTv/LiveTvService.cs +++ /dev/null @@ -1,1287 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Api.UserLibrary; -using MediaBrowser.Common; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace MediaBrowser.Api.LiveTv -{ - /// <summary> - /// This is insecure right now to avoid windows phone refactoring. - /// </summary> - [Route("/LiveTv/Info", "GET", Summary = "Gets available live tv services.")] - [Authenticated] - public class GetLiveTvInfo : IReturn<LiveTvInfo> - { - } - - [Route("/LiveTv/Channels", "GET", Summary = "Gets available live tv channels.")] - [Authenticated] - public class GetChannels : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - [ApiMember(Name = "Type", Description = "Optional filter by channel type.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public ChannelType? Type { get; set; } - - [ApiMember(Name = "UserId", Description = "Optional filter by user and attach user data.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - [ApiMember(Name = "IsMovie", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsMovie { get; set; } - - [ApiMember(Name = "IsSeries", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSeries { get; set; } - - [ApiMember(Name = "IsNews", Description = "Optional filter for news.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsNews { get; set; } - - [ApiMember(Name = "IsKids", Description = "Optional filter for kids.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsKids { get; set; } - - [ApiMember(Name = "IsSports", Description = "Optional filter for sports.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSports { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "IsFavorite", Description = "Filter by channels that are favorites, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsFavorite { get; set; } - - [ApiMember(Name = "IsLiked", Description = "Filter by channels that are liked, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsLiked { get; set; } - - [ApiMember(Name = "IsDisliked", Description = "Filter by channels that are disliked, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsDisliked { get; set; } - - [ApiMember(Name = "EnableFavoriteSorting", Description = "Incorporate favorite and like status into channel sorting.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool EnableFavoriteSorting { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - [ApiMember(Name = "AddCurrentProgram", Description = "Optional. Adds current program info to each channel", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool AddCurrentProgram { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - public string SortBy { get; set; } - - public SortOrder? SortOrder { get; set; } - - /// <summary> - /// Gets the order by. - /// </summary> - /// <returns>IEnumerable{ItemSortBy}.</returns> - public string[] GetOrderBy() - { - var val = SortBy; - - if (string.IsNullOrEmpty(val)) - { - return Array.Empty<string>(); - } - - return val.Split(','); - } - - public GetChannels() - { - AddCurrentProgram = true; - } - } - - [Route("/LiveTv/Channels/{Id}", "GET", Summary = "Gets a live tv channel")] - [Authenticated] - public class GetChannel : IReturn<BaseItemDto> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Channel Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "Optional attach user data.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - [Route("/LiveTv/Recordings", "GET", Summary = "Gets live tv recordings")] - [Authenticated] - public class GetRecordings : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - [ApiMember(Name = "ChannelId", Description = "Optional filter by channel id.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ChannelId { get; set; } - - [ApiMember(Name = "UserId", Description = "Optional filter by user and attach user data.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "Status", Description = "Optional filter by recording status.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public RecordingStatus? Status { get; set; } - - [ApiMember(Name = "Status", Description = "Optional filter by recordings that are in progress, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsInProgress { get; set; } - - [ApiMember(Name = "SeriesTimerId", Description = "Optional filter by recordings belonging to a series timer", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SeriesTimerId { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - public bool EnableTotalRecordCount { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - public bool? IsMovie { get; set; } - - public bool? IsSeries { get; set; } - - public bool? IsKids { get; set; } - - public bool? IsSports { get; set; } - - public bool? IsNews { get; set; } - - public bool? IsLibraryItem { get; set; } - - public GetRecordings() - { - EnableTotalRecordCount = true; - } - } - - [Route("/LiveTv/Recordings/Series", "GET", Summary = "Gets live tv recordings")] - [Authenticated] - public class GetRecordingSeries : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - [ApiMember(Name = "ChannelId", Description = "Optional filter by channel id.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ChannelId { get; set; } - - [ApiMember(Name = "UserId", Description = "Optional filter by user and attach user data.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string UserId { get; set; } - - [ApiMember(Name = "GroupId", Description = "Optional filter by recording group.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string GroupId { get; set; } - - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "Status", Description = "Optional filter by recording status.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public RecordingStatus? Status { get; set; } - - [ApiMember(Name = "Status", Description = "Optional filter by recordings that are in progress, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsInProgress { get; set; } - - [ApiMember(Name = "SeriesTimerId", Description = "Optional filter by recordings belonging to a series timer", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SeriesTimerId { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - public bool EnableTotalRecordCount { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - public GetRecordingSeries() - { - EnableTotalRecordCount = true; - } - } - - [Route("/LiveTv/Recordings/Groups", "GET", Summary = "Gets live tv recording groups")] - [Authenticated] - public class GetRecordingGroups : IReturn<QueryResult<BaseItemDto>> - { - [ApiMember(Name = "UserId", Description = "Optional filter by user and attach user data.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string UserId { get; set; } - } - - [Route("/LiveTv/Recordings/Folders", "GET", Summary = "Gets recording folders")] - [Authenticated] - public class GetRecordingFolders : IReturn<BaseItemDto[]> - { - [ApiMember(Name = "UserId", Description = "Optional filter by user and attach user data.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - [Route("/LiveTv/Recordings/{Id}", "GET", Summary = "Gets a live tv recording")] - [Authenticated] - public class GetRecording : IReturn<BaseItemDto> - { - [ApiMember(Name = "Id", Description = "Recording Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "Optional attach user data.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - [Route("/LiveTv/Tuners/{Id}/Reset", "POST", Summary = "Resets a tv tuner")] - [Authenticated] - public class ResetTuner : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Tuner Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/LiveTv/Timers/{Id}", "GET", Summary = "Gets a live tv timer")] - [Authenticated] - public class GetTimer : IReturn<TimerInfoDto> - { - [ApiMember(Name = "Id", Description = "Timer Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/LiveTv/Timers/Defaults", "GET", Summary = "Gets default values for a new timer")] - [Authenticated] - public class GetDefaultTimer : IReturn<SeriesTimerInfoDto> - { - [ApiMember(Name = "ProgramId", Description = "Optional, to attach default values based on a program.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ProgramId { get; set; } - } - - [Route("/LiveTv/Timers", "GET", Summary = "Gets live tv timers")] - [Authenticated] - public class GetTimers : IReturn<QueryResult<TimerInfoDto>> - { - [ApiMember(Name = "ChannelId", Description = "Optional filter by channel id.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ChannelId { get; set; } - - [ApiMember(Name = "SeriesTimerId", Description = "Optional filter by timers belonging to a series timer", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SeriesTimerId { get; set; } - - public bool? IsActive { get; set; } - - public bool? IsScheduled { get; set; } - } - - [Route("/LiveTv/Programs", "GET,POST", Summary = "Gets available live tv epgs..")] - [Authenticated] - public class GetPrograms : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - [ApiMember(Name = "ChannelIds", Description = "The channels to return guide information for.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string ChannelIds { get; set; } - - [ApiMember(Name = "UserId", Description = "Optional filter by user id.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public Guid UserId { get; set; } - - [ApiMember(Name = "MinStartDate", Description = "Optional. The minimum premiere date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string MinStartDate { get; set; } - - [ApiMember(Name = "HasAired", Description = "Optional. Filter by programs that have completed airing, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? HasAired { get; set; } - - public bool? IsAiring { get; set; } - - [ApiMember(Name = "MaxStartDate", Description = "Optional. The maximum premiere date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string MaxStartDate { get; set; } - - [ApiMember(Name = "MinEndDate", Description = "Optional. The minimum premiere date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string MinEndDate { get; set; } - - [ApiMember(Name = "MaxEndDate", Description = "Optional. The maximum premiere date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string MaxEndDate { get; set; } - - [ApiMember(Name = "IsMovie", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsMovie { get; set; } - - [ApiMember(Name = "IsSeries", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSeries { get; set; } - - [ApiMember(Name = "IsNews", Description = "Optional filter for news.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsNews { get; set; } - - [ApiMember(Name = "IsKids", Description = "Optional filter for kids.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsKids { get; set; } - - [ApiMember(Name = "IsSports", Description = "Optional filter for sports.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSports { get; set; } - - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "SortBy", Description = "Optional. Specify one or more sort orders, comma delimeted. Options: Name, StartDate", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string SortBy { get; set; } - - [ApiMember(Name = "SortOrder", Description = "Sort Order - Ascending,Descending", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SortOrder { get; set; } - - [ApiMember(Name = "Genres", Description = "The genres to return guide information for.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string Genres { get; set; } - - [ApiMember(Name = "GenreIds", Description = "The genres to return guide information for.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string GenreIds { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - public bool EnableTotalRecordCount { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - public string SeriesTimerId { get; set; } - - public Guid LibrarySeriesId { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - public GetPrograms() - { - EnableTotalRecordCount = true; - } - } - - [Route("/LiveTv/Programs/Recommended", "GET", Summary = "Gets available live tv epgs..")] - [Authenticated] - public class GetRecommendedPrograms : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - public bool EnableTotalRecordCount { get; set; } - - public GetRecommendedPrograms() - { - EnableTotalRecordCount = true; - } - - [ApiMember(Name = "UserId", Description = "Optional filter by user id.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public Guid UserId { get; set; } - - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "IsAiring", Description = "Optional. Filter by programs that are currently airing, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsAiring { get; set; } - - [ApiMember(Name = "HasAired", Description = "Optional. Filter by programs that have completed airing, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? HasAired { get; set; } - - [ApiMember(Name = "IsSeries", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSeries { get; set; } - - [ApiMember(Name = "IsMovie", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsMovie { get; set; } - - [ApiMember(Name = "IsNews", Description = "Optional filter for news.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsNews { get; set; } - - [ApiMember(Name = "IsKids", Description = "Optional filter for kids.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsKids { get; set; } - - [ApiMember(Name = "IsSports", Description = "Optional filter for sports.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSports { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - [ApiMember(Name = "GenreIds", Description = "The genres to return guide information for.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string GenreIds { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - } - - [Route("/LiveTv/Programs/{Id}", "GET", Summary = "Gets a live tv program")] - [Authenticated] - public class GetProgram : IReturn<BaseItemDto> - { - [ApiMember(Name = "Id", Description = "Program Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "Optional attach user data.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - - [Route("/LiveTv/Recordings/{Id}", "DELETE", Summary = "Deletes a live tv recording")] - [Authenticated] - public class DeleteRecording : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Recording Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - } - - [Route("/LiveTv/Timers/{Id}", "DELETE", Summary = "Cancels a live tv timer")] - [Authenticated] - public class CancelTimer : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Timer Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/LiveTv/Timers/{Id}", "POST", Summary = "Updates a live tv timer")] - [Authenticated] - public class UpdateTimer : TimerInfoDto, IReturnVoid - { - } - - [Route("/LiveTv/Timers", "POST", Summary = "Creates a live tv timer")] - [Authenticated] - public class CreateTimer : TimerInfoDto, IReturnVoid - { - } - - [Route("/LiveTv/SeriesTimers/{Id}", "GET", Summary = "Gets a live tv series timer")] - [Authenticated] - public class GetSeriesTimer : IReturn<TimerInfoDto> - { - [ApiMember(Name = "Id", Description = "Timer Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/LiveTv/SeriesTimers", "GET", Summary = "Gets live tv series timers")] - [Authenticated] - public class GetSeriesTimers : IReturn<QueryResult<SeriesTimerInfoDto>> - { - [ApiMember(Name = "SortBy", Description = "Optional. Sort by SortName or Priority", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public string SortBy { get; set; } - - [ApiMember(Name = "SortOrder", Description = "Optional. Sort in Ascending or Descending order", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET,POST")] - public SortOrder SortOrder { get; set; } - } - - [Route("/LiveTv/SeriesTimers/{Id}", "DELETE", Summary = "Cancels a live tv series timer")] - [Authenticated] - public class CancelSeriesTimer : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Timer Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/LiveTv/SeriesTimers/{Id}", "POST", Summary = "Updates a live tv series timer")] - [Authenticated] - public class UpdateSeriesTimer : SeriesTimerInfoDto, IReturnVoid - { - } - - [Route("/LiveTv/SeriesTimers", "POST", Summary = "Creates a live tv series timer")] - [Authenticated] - public class CreateSeriesTimer : SeriesTimerInfoDto, IReturnVoid - { - } - - [Route("/LiveTv/Recordings/Groups/{Id}", "GET", Summary = "Gets a recording group")] - [Authenticated] - public class GetRecordingGroup : IReturn<BaseItemDto> - { - [ApiMember(Name = "Id", Description = "Recording group Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/LiveTv/GuideInfo", "GET", Summary = "Gets guide info")] - [Authenticated] - public class GetGuideInfo : IReturn<GuideInfo> - { - } - - [Route("/LiveTv/TunerHosts", "POST", Summary = "Adds a tuner host")] - [Authenticated] - public class AddTunerHost : TunerHostInfo, IReturn<TunerHostInfo> - { - } - - [Route("/LiveTv/TunerHosts", "DELETE", Summary = "Deletes a tuner host")] - [Authenticated] - public class DeleteTunerHost : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Tuner host id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/LiveTv/ListingProviders/Default", "GET")] - [Authenticated] - public class GetDefaultListingProvider : ListingsProviderInfo, IReturn<ListingsProviderInfo> - { - } - - [Route("/LiveTv/ListingProviders", "POST", Summary = "Adds a listing provider")] - [Authenticated] - public class AddListingProvider : ListingsProviderInfo, IReturn<ListingsProviderInfo> - { - public bool ValidateLogin { get; set; } - - public bool ValidateListings { get; set; } - - public string Pw { get; set; } - } - - [Route("/LiveTv/ListingProviders", "DELETE", Summary = "Deletes a listing provider")] - [Authenticated] - public class DeleteListingProvider : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Provider id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/LiveTv/ListingProviders/Lineups", "GET", Summary = "Gets available lineups")] - [Authenticated] - public class GetLineups : IReturn<List<NameIdPair>> - { - [ApiMember(Name = "Id", Description = "Provider id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "Type", Description = "Provider Type", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Type { get; set; } - - [ApiMember(Name = "Location", Description = "Location", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Location { get; set; } - - [ApiMember(Name = "Country", Description = "Country", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Country { get; set; } - } - - [Route("/LiveTv/ListingProviders/SchedulesDirect/Countries", "GET", Summary = "Gets available lineups")] - [Authenticated] - public class GetSchedulesDirectCountries - { - } - - [Route("/LiveTv/ChannelMappingOptions")] - [Authenticated] - public class GetChannelMappingOptions - { - [ApiMember(Name = "Id", Description = "Provider id", IsRequired = true, DataType = "string", ParameterType = "query")] - public string ProviderId { get; set; } - } - - [Route("/LiveTv/ChannelMappings")] - [Authenticated] - public class SetChannelMapping - { - [ApiMember(Name = "Id", Description = "Provider id", IsRequired = true, DataType = "string", ParameterType = "query")] - public string ProviderId { get; set; } - - public string TunerChannelId { get; set; } - - public string ProviderChannelId { get; set; } - } - - public class ChannelMappingOptions - { - public List<TunerChannelMapping> TunerChannels { get; set; } - - public List<NameIdPair> ProviderChannels { get; set; } - - public NameValuePair[] Mappings { get; set; } - - public string ProviderName { get; set; } - } - - [Route("/LiveTv/LiveStreamFiles/{Id}/stream.{Container}", "GET", Summary = "Gets a live tv channel")] - public class GetLiveStreamFile - { - public string Id { get; set; } - - public string Container { get; set; } - } - - [Route("/LiveTv/LiveRecordings/{Id}/stream", "GET", Summary = "Gets a live tv channel")] - public class GetLiveRecordingFile - { - public string Id { get; set; } - } - - [Route("/LiveTv/TunerHosts/Types", "GET")] - [Authenticated] - public class GetTunerHostTypes : IReturn<List<NameIdPair>> - { - } - - [Route("/LiveTv/Tuners/Discvover", "GET")] - [Authenticated] - public class DiscoverTuners : IReturn<List<TunerHostInfo>> - { - public bool NewDevicesOnly { get; set; } - } - - public class LiveTvService : BaseApiService - { - private readonly ILiveTvManager _liveTvManager; - private readonly IUserManager _userManager; - private readonly IHttpClient _httpClient; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - private readonly ISessionContext _sessionContext; - private readonly IStreamHelper _streamHelper; - private readonly IMediaSourceManager _mediaSourceManager; - - public LiveTvService( - ILogger<LiveTvService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IMediaSourceManager mediaSourceManager, - IStreamHelper streamHelper, - ILiveTvManager liveTvManager, - IUserManager userManager, - IHttpClient httpClient, - ILibraryManager libraryManager, - IDtoService dtoService, - IAuthorizationContext authContext, - ISessionContext sessionContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _mediaSourceManager = mediaSourceManager; - _streamHelper = streamHelper; - _liveTvManager = liveTvManager; - _userManager = userManager; - _httpClient = httpClient; - _libraryManager = libraryManager; - _dtoService = dtoService; - _authContext = authContext; - _sessionContext = sessionContext; - } - - public object Get(GetTunerHostTypes request) - { - var list = _liveTvManager.GetTunerHostTypes(); - return ToOptimizedResult(list); - } - - public object Get(GetRecordingFolders request) - { - var user = request.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(request.UserId); - var folders = _liveTvManager.GetRecordingFolders(user); - - var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user); - - var result = new QueryResult<BaseItemDto> - { - Items = returnArray, - TotalRecordCount = returnArray.Count - }; - - return ToOptimizedResult(result); - } - - public object Get(GetLiveRecordingFile request) - { - var path = _liveTvManager.GetEmbyTvActiveRecordingPath(request.Id); - - if (string.IsNullOrWhiteSpace(path)) - { - throw new FileNotFoundException(); - } - - var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) - { - [HeaderNames.ContentType] = Model.Net.MimeTypes.GetMimeType(path) - }; - - return new ProgressiveFileCopier(_streamHelper, path, outputHeaders, Logger) - { - AllowEndOfFile = false - }; - } - - public async Task<object> Get(DiscoverTuners request) - { - var result = await _liveTvManager.DiscoverTuners(request.NewDevicesOnly, CancellationToken.None).ConfigureAwait(false); - return ToOptimizedResult(result); - } - - public async Task<object> Get(GetLiveStreamFile request) - { - var liveStreamInfo = await _mediaSourceManager.GetDirectStreamProviderByUniqueId(request.Id, CancellationToken.None).ConfigureAwait(false); - - var directStreamProvider = liveStreamInfo; - - var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) - { - [HeaderNames.ContentType] = Model.Net.MimeTypes.GetMimeType("file." + request.Container) - }; - - return new ProgressiveFileCopier(directStreamProvider, _streamHelper, outputHeaders, Logger) - { - AllowEndOfFile = false - }; - } - - public object Get(GetDefaultListingProvider request) - { - return ToOptimizedResult(new ListingsProviderInfo()); - } - - public async Task<object> Post(SetChannelMapping request) - { - return await _liveTvManager.SetChannelMapping(request.ProviderId, request.TunerChannelId, request.ProviderChannelId).ConfigureAwait(false); - } - - public async Task<object> Get(GetChannelMappingOptions request) - { - var config = GetConfiguration(); - - var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(request.ProviderId, i.Id, StringComparison.OrdinalIgnoreCase)); - - var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; - - var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(request.ProviderId, CancellationToken.None) - .ConfigureAwait(false); - - var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(request.ProviderId, CancellationToken.None) - .ConfigureAwait(false); - - var mappings = listingsProviderInfo.ChannelMappings; - - var result = new ChannelMappingOptions - { - TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), - - ProviderChannels = providerChannels.Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Id - }).ToList(), - - Mappings = mappings, - - ProviderName = listingsProviderName - }; - - return ToOptimizedResult(result); - } - - public async Task<object> Get(GetSchedulesDirectCountries request) - { - // https://json.schedulesdirect.org/20141201/available/countries - - var response = await _httpClient.Get(new HttpRequestOptions - { - Url = "https://json.schedulesdirect.org/20141201/available/countries", - BufferContent = false - }).ConfigureAwait(false); - - return ResultFactory.GetResult(Request, response, "application/json"); - } - - private void AssertUserCanManageLiveTv() - { - var user = _sessionContext.GetUser(Request); - - if (user == null) - { - throw new SecurityException("Anonymous live tv management is not allowed."); - } - - if (!user.HasPermission(PermissionKind.EnableLiveTvManagement)) - { - throw new SecurityException("The current user does not have permission to manage live tv."); - } - } - - public async Task<object> Post(AddListingProvider request) - { - if (request.Pw != null) - { - request.Password = GetHashedString(request.Pw); - } - - request.Pw = null; - - var result = await _liveTvManager.SaveListingProvider(request, request.ValidateLogin, request.ValidateListings).ConfigureAwait(false); - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the hashed string. - /// </summary> - private string GetHashedString(string str) - { - // SchedulesDirect requires a SHA1 hash of the user's password - // https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#obtain-a-token - using SHA1 sha = SHA1.Create(); - - return Hex.Encode( - sha.ComputeHash(Encoding.UTF8.GetBytes(str))); - } - - public void Delete(DeleteListingProvider request) - { - _liveTvManager.DeleteListingsProvider(request.Id); - } - - public async Task<object> Post(AddTunerHost request) - { - var result = await _liveTvManager.SaveTunerHost(request).ConfigureAwait(false); - return ToOptimizedResult(result); - } - - public void Delete(DeleteTunerHost request) - { - var config = GetConfiguration(); - - config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(request.Id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); - - ServerConfigurationManager.SaveConfiguration("livetv", config); - } - - private LiveTvOptions GetConfiguration() - { - return ServerConfigurationManager.GetConfiguration<LiveTvOptions>("livetv"); - } - - private void UpdateConfiguration(LiveTvOptions options) - { - ServerConfigurationManager.SaveConfiguration("livetv", options); - } - - public async Task<object> Get(GetLineups request) - { - var info = await _liveTvManager.GetLineups(request.Type, request.Id, request.Country, request.Location).ConfigureAwait(false); - - return ToOptimizedResult(info); - } - - public object Get(GetLiveTvInfo request) - { - var info = _liveTvManager.GetLiveTvInfo(CancellationToken.None); - - return ToOptimizedResult(info); - } - - public object Get(GetChannels request) - { - var options = GetDtoOptions(_authContext, request); - - var channelResult = _liveTvManager.GetInternalChannels(new LiveTvChannelQuery - { - ChannelType = request.Type, - UserId = request.UserId, - StartIndex = request.StartIndex, - Limit = request.Limit, - IsFavorite = request.IsFavorite, - IsLiked = request.IsLiked, - IsDisliked = request.IsDisliked, - EnableFavoriteSorting = request.EnableFavoriteSorting, - IsMovie = request.IsMovie, - IsSeries = request.IsSeries, - IsNews = request.IsNews, - IsKids = request.IsKids, - IsSports = request.IsSports, - SortBy = request.GetOrderBy(), - SortOrder = request.SortOrder ?? SortOrder.Ascending, - AddCurrentProgram = request.AddCurrentProgram - }, options, CancellationToken.None); - - var user = request.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(request.UserId); - - RemoveFields(options); - - options.AddCurrentProgram = request.AddCurrentProgram; - - var returnArray = _dtoService.GetBaseItemDtos(channelResult.Items, options, user); - - var result = new QueryResult<BaseItemDto> - { - Items = returnArray, - TotalRecordCount = channelResult.TotalRecordCount - }; - - return ToOptimizedResult(result); - } - - private void RemoveFields(DtoOptions options) - { - var fields = options.Fields.ToList(); - - fields.Remove(ItemFields.CanDelete); - fields.Remove(ItemFields.CanDownload); - fields.Remove(ItemFields.DisplayPreferencesId); - fields.Remove(ItemFields.Etag); - options.Fields = fields.ToArray(); - } - - public object Get(GetChannel request) - { - var user = _userManager.GetUserById(request.UserId); - - var item = string.IsNullOrEmpty(request.Id) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(request.Id); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = _dtoService.GetBaseItemDto(item, dtoOptions, user); - - return ToOptimizedResult(result); - } - - public async Task<object> Get(GetPrograms request) - { - var user = request.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(request.UserId); - - var query = new InternalItemsQuery(user) - { - ChannelIds = ApiEntryPoint.Split(request.ChannelIds, ',', true).Select(i => new Guid(i)).ToArray(), - HasAired = request.HasAired, - IsAiring = request.IsAiring, - EnableTotalRecordCount = request.EnableTotalRecordCount - }; - - if (!string.IsNullOrEmpty(request.MinStartDate)) - { - query.MinStartDate = DateTime.Parse(request.MinStartDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - } - - if (!string.IsNullOrEmpty(request.MinEndDate)) - { - query.MinEndDate = DateTime.Parse(request.MinEndDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - } - - if (!string.IsNullOrEmpty(request.MaxStartDate)) - { - query.MaxStartDate = DateTime.Parse(request.MaxStartDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - } - - if (!string.IsNullOrEmpty(request.MaxEndDate)) - { - query.MaxEndDate = DateTime.Parse(request.MaxEndDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - } - - query.StartIndex = request.StartIndex; - query.Limit = request.Limit; - query.OrderBy = BaseItemsRequest.GetOrderBy(request.SortBy, request.SortOrder); - query.IsNews = request.IsNews; - query.IsMovie = request.IsMovie; - query.IsSeries = request.IsSeries; - query.IsKids = request.IsKids; - query.IsSports = request.IsSports; - query.SeriesTimerId = request.SeriesTimerId; - query.Genres = (request.Genres ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - query.GenreIds = GetGuids(request.GenreIds); - - if (!request.LibrarySeriesId.Equals(Guid.Empty)) - { - query.IsSeries = true; - - if (_libraryManager.GetItemById(request.LibrarySeriesId) is Series series) - { - query.Name = series.Name; - } - } - - var result = await _liveTvManager.GetPrograms(query, GetDtoOptions(_authContext, request), CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public object Get(GetRecommendedPrograms request) - { - var user = _userManager.GetUserById(request.UserId); - - var query = new InternalItemsQuery(user) - { - IsAiring = request.IsAiring, - Limit = request.Limit, - HasAired = request.HasAired, - IsSeries = request.IsSeries, - IsMovie = request.IsMovie, - IsKids = request.IsKids, - IsNews = request.IsNews, - IsSports = request.IsSports, - EnableTotalRecordCount = request.EnableTotalRecordCount - }; - - query.GenreIds = GetGuids(request.GenreIds); - - var result = _liveTvManager.GetRecommendedPrograms(query, GetDtoOptions(_authContext, request), CancellationToken.None); - - return ToOptimizedResult(result); - } - - public object Post(GetPrograms request) - { - return Get(request); - } - - public object Get(GetRecordings request) - { - var options = GetDtoOptions(_authContext, request); - - var result = _liveTvManager.GetRecordings(new RecordingQuery - { - ChannelId = request.ChannelId, - UserId = request.UserId, - StartIndex = request.StartIndex, - Limit = request.Limit, - Status = request.Status, - SeriesTimerId = request.SeriesTimerId, - IsInProgress = request.IsInProgress, - EnableTotalRecordCount = request.EnableTotalRecordCount, - IsMovie = request.IsMovie, - IsNews = request.IsNews, - IsSeries = request.IsSeries, - IsKids = request.IsKids, - IsSports = request.IsSports, - IsLibraryItem = request.IsLibraryItem, - Fields = request.GetItemFields(), - ImageTypeLimit = request.ImageTypeLimit, - EnableImages = request.EnableImages - }, options); - - return ToOptimizedResult(result); - } - - public object Get(GetRecordingSeries request) - { - return ToOptimizedResult(new QueryResult<BaseItemDto>()); - } - - public object Get(GetRecording request) - { - var user = _userManager.GetUserById(request.UserId); - - var item = string.IsNullOrEmpty(request.Id) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(request.Id); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = _dtoService.GetBaseItemDto(item, dtoOptions, user); - - return ToOptimizedResult(result); - } - - public async Task<object> Get(GetTimer request) - { - var result = await _liveTvManager.GetTimer(request.Id, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Get(GetTimers request) - { - var result = await _liveTvManager.GetTimers(new TimerQuery - { - ChannelId = request.ChannelId, - SeriesTimerId = request.SeriesTimerId, - IsActive = request.IsActive, - IsScheduled = request.IsScheduled - }, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public void Delete(DeleteRecording request) - { - AssertUserCanManageLiveTv(); - - _libraryManager.DeleteItem(_libraryManager.GetItemById(request.Id), new DeleteOptions - { - DeleteFileLocation = false - }); - } - - public Task Delete(CancelTimer request) - { - AssertUserCanManageLiveTv(); - - return _liveTvManager.CancelTimer(request.Id); - } - - public Task Post(UpdateTimer request) - { - AssertUserCanManageLiveTv(); - - return _liveTvManager.UpdateTimer(request, CancellationToken.None); - } - - public async Task<object> Get(GetSeriesTimers request) - { - var result = await _liveTvManager.GetSeriesTimers(new SeriesTimerQuery - { - SortOrder = request.SortOrder, - SortBy = request.SortBy - }, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Get(GetSeriesTimer request) - { - var result = await _liveTvManager.GetSeriesTimer(request.Id, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public Task Delete(CancelSeriesTimer request) - { - AssertUserCanManageLiveTv(); - - return _liveTvManager.CancelSeriesTimer(request.Id); - } - - public Task Post(UpdateSeriesTimer request) - { - AssertUserCanManageLiveTv(); - - return _liveTvManager.UpdateSeriesTimer(request, CancellationToken.None); - } - - public async Task<object> Get(GetDefaultTimer request) - { - if (string.IsNullOrEmpty(request.ProgramId)) - { - var result = await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - else - { - var result = await _liveTvManager.GetNewTimerDefaults(request.ProgramId, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - } - - public async Task<object> Get(GetProgram request) - { - var user = request.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(request.UserId); - - var result = await _liveTvManager.GetProgram(request.Id, CancellationToken.None, user).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public Task Post(CreateSeriesTimer request) - { - AssertUserCanManageLiveTv(); - - return _liveTvManager.CreateSeriesTimer(request, CancellationToken.None); - } - - public Task Post(CreateTimer request) - { - AssertUserCanManageLiveTv(); - - return _liveTvManager.CreateTimer(request, CancellationToken.None); - } - - public object Get(GetRecordingGroups request) - { - return ToOptimizedResult(new QueryResult<BaseItemDto>()); - } - - public object Get(GetRecordingGroup request) - { - throw new FileNotFoundException(); - } - - public object Get(GetGuideInfo request) - { - return ToOptimizedResult(_liveTvManager.GetGuideInfo()); - } - - public Task Post(ResetTuner request) - { - AssertUserCanManageLiveTv(); - - return _liveTvManager.ResetTuner(request.Id, CancellationToken.None); - } - } -} diff --git a/MediaBrowser.Api/LiveTv/ProgressiveFileCopier.cs b/MediaBrowser.Api/LiveTv/ProgressiveFileCopier.cs deleted file mode 100644 index 4c608d9a3..000000000 --- a/MediaBrowser.Api/LiveTv/ProgressiveFileCopier.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.LiveTv -{ - public class ProgressiveFileCopier : IAsyncStreamWriter, IHasHeaders - { - private readonly ILogger _logger; - private readonly string _path; - private readonly Dictionary<string, string> _outputHeaders; - - public bool AllowEndOfFile = true; - - private readonly IDirectStreamProvider _directStreamProvider; - private IStreamHelper _streamHelper; - - public ProgressiveFileCopier(IStreamHelper streamHelper, string path, Dictionary<string, string> outputHeaders, ILogger logger) - { - _path = path; - _outputHeaders = outputHeaders; - _logger = logger; - _streamHelper = streamHelper; - } - - public ProgressiveFileCopier(IDirectStreamProvider directStreamProvider, IStreamHelper streamHelper, Dictionary<string, string> outputHeaders, ILogger logger) - { - _directStreamProvider = directStreamProvider; - _outputHeaders = outputHeaders; - _logger = logger; - _streamHelper = streamHelper; - } - - public IDictionary<string, string> Headers => _outputHeaders; - - public async Task WriteToAsync(Stream outputStream, CancellationToken cancellationToken) - { - if (_directStreamProvider != null) - { - await _directStreamProvider.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); - return; - } - - var fileOptions = FileOptions.SequentialScan; - - // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 - if (Environment.OSVersion.Platform != PlatformID.Win32NT) - { - fileOptions |= FileOptions.Asynchronous; - } - - using (var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, fileOptions)) - { - var emptyReadLimit = AllowEndOfFile ? 20 : 100; - var eofCount = 0; - while (eofCount < emptyReadLimit) - { - int bytesRead; - bytesRead = await _streamHelper.CopyToAsync(inputStream, outputStream, cancellationToken).ConfigureAwait(false); - - if (bytesRead == 0) - { - eofCount++; - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - else - { - eofCount = 0; - } - } - } - } - } -} diff --git a/MediaBrowser.Api/LocalizationService.cs b/MediaBrowser.Api/LocalizationService.cs deleted file mode 100644 index d6b5f5195..000000000 --- a/MediaBrowser.Api/LocalizationService.cs +++ /dev/null @@ -1,111 +0,0 @@ -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class GetCultures. - /// </summary> - [Route("/Localization/Cultures", "GET", Summary = "Gets known cultures")] - public class GetCultures : IReturn<CultureDto[]> - { - } - - /// <summary> - /// Class GetCountries. - /// </summary> - [Route("/Localization/Countries", "GET", Summary = "Gets known countries")] - public class GetCountries : IReturn<CountryInfo[]> - { - } - - /// <summary> - /// Class ParentalRatings. - /// </summary> - [Route("/Localization/ParentalRatings", "GET", Summary = "Gets known parental ratings")] - public class GetParentalRatings : IReturn<ParentalRating[]> - { - } - - /// <summary> - /// Class ParentalRatings. - /// </summary> - [Route("/Localization/Options", "GET", Summary = "Gets localization options")] - public class GetLocalizationOptions : IReturn<LocalizationOption[]> - { - } - - /// <summary> - /// Class CulturesService. - /// </summary> - [Authenticated(AllowBeforeStartupWizard = true)] - public class LocalizationService : BaseApiService - { - /// <summary> - /// The _localization. - /// </summary> - private readonly ILocalizationManager _localization; - - /// <summary> - /// Initializes a new instance of the <see cref="LocalizationService"/> class. - /// </summary> - /// <param name="localization">The localization.</param> - public LocalizationService( - ILogger<LocalizationService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ILocalizationManager localization) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _localization = localization; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetParentalRatings request) - { - var result = _localization.GetParentalRatings(); - - return ToOptimizedResult(result); - } - - public object Get(GetLocalizationOptions request) - { - var result = _localization.GetLocalizationOptions(); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetCountries request) - { - var result = _localization.GetCountries(); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetCultures request) - { - var result = _localization.GetCultures(); - - return ToOptimizedResult(result); - } - } - -} diff --git a/MediaBrowser.Api/MediaBrowser.Api.csproj b/MediaBrowser.Api/MediaBrowser.Api.csproj deleted file mode 100644 index d703bdb05..000000000 --- a/MediaBrowser.Api/MediaBrowser.Api.csproj +++ /dev/null @@ -1,23 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> - <PropertyGroup> - <ProjectGuid>{4FD51AC5-2C16-4308-A993-C3A84F3B4582}</ProjectGuid> - </PropertyGroup> - - <ItemGroup> - <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> - <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> - </ItemGroup> - - <ItemGroup> - <Compile Include="..\SharedVersion.cs" /> - </ItemGroup> - - <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <GenerateDocumentationFile>true</GenerateDocumentationFile> - </PropertyGroup> - -</Project> diff --git a/MediaBrowser.Api/Movies/CollectionService.cs b/MediaBrowser.Api/Movies/CollectionService.cs deleted file mode 100644 index e9629439d..000000000 --- a/MediaBrowser.Api/Movies/CollectionService.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using MediaBrowser.Controller.Collections; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Collections; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Movies -{ - [Route("/Collections", "POST", Summary = "Creates a new collection")] - public class CreateCollection : IReturn<CollectionCreationResult> - { - [ApiMember(Name = "IsLocked", Description = "Whether or not to lock the new collection.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool IsLocked { get; set; } - - [ApiMember(Name = "Name", Description = "The name of the new collection.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Name { get; set; } - - [ApiMember(Name = "ParentId", Description = "Optional - create the collection within a specific folder", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ParentId { get; set; } - - [ApiMember(Name = "Ids", Description = "Item Ids to add to the collection", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)] - public string Ids { get; set; } - } - - [Route("/Collections/{Id}/Items", "POST", Summary = "Adds items to a collection")] - public class AddToCollection : IReturnVoid - { - [ApiMember(Name = "Ids", Description = "Item id, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Ids { get; set; } - - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Collections/{Id}/Items", "DELETE", Summary = "Removes items from a collection")] - public class RemoveFromCollection : IReturnVoid - { - [ApiMember(Name = "Ids", Description = "Item id, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string Ids { get; set; } - - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - [Authenticated] - public class CollectionService : BaseApiService - { - private readonly ICollectionManager _collectionManager; - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - - public CollectionService( - ILogger<CollectionService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ICollectionManager collectionManager, - IDtoService dtoService, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _collectionManager = collectionManager; - _dtoService = dtoService; - _authContext = authContext; - } - - public object Post(CreateCollection request) - { - var userId = _authContext.GetAuthorizationInfo(Request).UserId; - - var parentId = string.IsNullOrWhiteSpace(request.ParentId) ? (Guid?)null : new Guid(request.ParentId); - - var item = _collectionManager.CreateCollection(new CollectionCreationOptions - { - IsLocked = request.IsLocked, - Name = request.Name, - ParentId = parentId, - ItemIdList = SplitValue(request.Ids, ','), - UserIds = new[] { userId } - }); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var dto = _dtoService.GetBaseItemDto(item, dtoOptions); - - return new CollectionCreationResult - { - Id = dto.Id - }; - } - - public void Post(AddToCollection request) - { - _collectionManager.AddToCollection(new Guid(request.Id), SplitValue(request.Ids, ',')); - } - - public void Delete(RemoveFromCollection request) - { - _collectionManager.RemoveFromCollection(new Guid(request.Id), SplitValue(request.Ids, ',')); - } - } -} diff --git a/MediaBrowser.Api/Movies/MoviesService.cs b/MediaBrowser.Api/Movies/MoviesService.cs deleted file mode 100644 index 34cccffa3..000000000 --- a/MediaBrowser.Api/Movies/MoviesService.cs +++ /dev/null @@ -1,414 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using Jellyfin.Data.Entities; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; -using Movie = MediaBrowser.Controller.Entities.Movies.Movie; - -namespace MediaBrowser.Api.Movies -{ - [Route("/Movies/Recommendations", "GET", Summary = "Gets movie recommendations")] - public class GetMovieRecommendations : IReturn<RecommendationDto[]>, IHasDtoOptions - { - [ApiMember(Name = "CategoryLimit", Description = "The max number of categories to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int CategoryLimit { get; set; } - - [ApiMember(Name = "ItemLimit", Description = "The max number of items to return per category", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int ItemLimit { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// </summary> - /// <value>The parent id.</value> - [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ParentId { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - public GetMovieRecommendations() - { - CategoryLimit = 5; - ItemLimit = 8; - } - - public string Fields { get; set; } - } - - /// <summary> - /// Class MoviesService. - /// </summary> - [Authenticated] - public class MoviesService : BaseApiService - { - /// <summary> - /// The _user manager. - /// </summary> - private readonly IUserManager _userManager; - - private readonly ILibraryManager _libraryManager; - - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - - /// <summary> - /// Initializes a new instance of the <see cref="MoviesService" /> class. - /// </summary> - public MoviesService( - ILogger<MoviesService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - _authContext = authContext; - } - - public object Get(GetMovieRecommendations request) - { - var user = _userManager.GetUserById(request.UserId); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = GetRecommendationCategories(user, request.ParentId, request.CategoryLimit, request.ItemLimit, dtoOptions); - - return ToOptimizedResult(result); - } - - public QueryResult<BaseItemDto> GetSimilarItemsResult(BaseGetSimilarItemsFromItem request) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var item = string.IsNullOrEmpty(request.Id) ? - (!request.UserId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : - _libraryManager.RootFolder) : _libraryManager.GetItemById(request.Id); - - var itemTypes = new List<string> { typeof(Movie).Name }; - if (ServerConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(typeof(Trailer).Name); - itemTypes.Add(typeof(LiveTvProgram).Name); - } - - var dtoOptions = GetDtoOptions(_authContext, request); - - var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Limit = request.Limit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - SimilarTo = item, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - - }); - - var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); - - var result = new QueryResult<BaseItemDto> - { - Items = returnList, - - TotalRecordCount = itemsResult.Count - }; - - return result; - } - - private IEnumerable<RecommendationDto> GetRecommendationCategories(User user, string parentId, int categoryLimit, int itemLimit, DtoOptions dtoOptions) - { - var categories = new List<RecommendationDto>(); - - var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); - - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] - { - typeof(Movie).Name, - // typeof(Trailer).Name, - // typeof(LiveTvProgram).Name - }, - // IsMovie = true - OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), - Limit = 7, - ParentId = parentIdGuid, - Recursive = true, - IsPlayed = true, - DtoOptions = dtoOptions - }; - - var recentlyPlayedMovies = _libraryManager.GetItemList(query); - - var itemTypes = new List<string> { typeof(Movie).Name }; - if (ServerConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(typeof(Trailer).Name); - itemTypes.Add(typeof(LiveTvProgram).Name); - } - - var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), - Limit = 10, - IsFavoriteOrLiked = true, - ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), - EnableGroupByMetadataKey = true, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions - }); - - var mostRecentMovies = recentlyPlayedMovies.Take(6).ToList(); - // Get recently played directors - var recentDirectors = GetDirectors(mostRecentMovies) - .ToList(); - - // Get recently played actors - var recentActors = GetActors(mostRecentMovies) - .ToList(); - - var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); - var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); - - var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); - var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); - - var categoryTypes = new List<IEnumerator<RecommendationDto>> - { - // Give this extra weight - similarToRecentlyPlayed, - similarToRecentlyPlayed, - - // Give this extra weight - similarToLiked, - similarToLiked, - - hasDirectorFromRecentlyPlayed, - hasActorFromRecentlyPlayed - }; - - while (categories.Count < categoryLimit) - { - var allEmpty = true; - - foreach (var category in categoryTypes) - { - if (category.MoveNext()) - { - categories.Add(category.Current); - allEmpty = false; - - if (categories.Count >= categoryLimit) - { - break; - } - } - } - - if (allEmpty) - { - break; - } - } - - return categories.OrderBy(i => i.RecommendationType); - } - - private IEnumerable<RecommendationDto> GetWithDirector( - User user, - IEnumerable<string> names, - int itemLimit, - DtoOptions dtoOptions, - RecommendationType type) - { - var itemTypes = new List<string> { typeof(Movie).Name }; - if (ServerConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(typeof(Trailer).Name); - itemTypes.Add(typeof(LiveTvProgram).Name); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by imdb id, since the database doesn't support this yet - Limit = itemLimit + 2, - PersonTypes = new[] { PersonType.Director }, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).GroupBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Select(x => x.First()) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable<RecommendationDto> GetWithActor(User user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) - { - var itemTypes = new List<string> { typeof(Movie).Name }; - if (ServerConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(typeof(Trailer).Name); - itemTypes.Add(typeof(LiveTvProgram).Name); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by imdb id, since the database doesn't support this yet - Limit = itemLimit + 2, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).GroupBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Select(x => x.First()) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable<RecommendationDto> GetSimilarTo(User user, List<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) - { - var itemTypes = new List<string> { typeof(Movie).Name }; - if (ServerConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(typeof(Trailer).Name); - itemTypes.Add(typeof(LiveTvProgram).Name); - } - - foreach (var item in baselineItems) - { - var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Limit = itemLimit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - SimilarTo = item, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }); - - if (similar.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable<string> GetActors(List<BaseItem> items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery - { - ExcludePersonTypes = new[] - { - PersonType.Director - }, - MaxListOrder = 3 - }); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } - - private IEnumerable<string> GetDirectors(List<BaseItem> items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery - { - PersonTypes = new[] - { - PersonType.Director - } - }); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } - } -} diff --git a/MediaBrowser.Api/Movies/TrailersService.cs b/MediaBrowser.Api/Movies/TrailersService.cs deleted file mode 100644 index ca9f9d03b..000000000 --- a/MediaBrowser.Api/Movies/TrailersService.cs +++ /dev/null @@ -1,88 +0,0 @@ -using MediaBrowser.Api.UserLibrary; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Movies -{ - [Route("/Trailers", "GET", Summary = "Finds movies and trailers similar to a given trailer.")] - public class Getrailers : BaseItemsRequest, IReturn<QueryResult<BaseItemDto>> - { - } - - /// <summary> - /// Class TrailersService. - /// </summary> - [Authenticated] - public class TrailersService : BaseApiService - { - /// <summary> - /// The _user manager. - /// </summary> - private readonly IUserManager _userManager; - - /// <summary> - /// The _library manager. - /// </summary> - private readonly ILibraryManager _libraryManager; - - /// <summary> - /// The logger for the created <see cref="ItemsService"/> instances. - /// </summary> - private readonly ILogger<ItemsService> _logger; - - private readonly IDtoService _dtoService; - private readonly ILocalizationManager _localizationManager; - private readonly IJsonSerializer _json; - private readonly IAuthorizationContext _authContext; - - public TrailersService( - ILoggerFactory loggerFactory, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - ILocalizationManager localizationManager, - IJsonSerializer json, - IAuthorizationContext authContext) - : base(loggerFactory.CreateLogger<TrailersService>(), serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - _localizationManager = localizationManager; - _json = json; - _authContext = authContext; - _logger = loggerFactory.CreateLogger<ItemsService>(); - } - - public object Get(Getrailers request) - { - var json = _json.SerializeToString(request); - var getItems = _json.DeserializeFromString<GetItems>(json); - - getItems.IncludeItemTypes = "Trailer"; - - return new ItemsService( - _logger, - ServerConfigurationManager, - ResultFactory, - _userManager, - _libraryManager, - _localizationManager, - _dtoService, - _authContext) - { - Request = Request, - }.Get(getItems); - } - } -} diff --git a/MediaBrowser.Api/Music/AlbumsService.cs b/MediaBrowser.Api/Music/AlbumsService.cs deleted file mode 100644 index 74d3cce12..000000000 --- a/MediaBrowser.Api/Music/AlbumsService.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Music -{ - [Route("/Albums/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")] - public class GetSimilarAlbums : BaseGetSimilarItemsFromItem - { - } - - [Route("/Artists/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")] - public class GetSimilarArtists : BaseGetSimilarItemsFromItem - { - } - - [Authenticated] - public class AlbumsService : BaseApiService - { - /// <summary> - /// The _user manager. - /// </summary> - private readonly IUserManager _userManager; - - /// <summary> - /// The _user data repository. - /// </summary> - private readonly IUserDataManager _userDataRepository; - /// <summary> - /// The _library manager. - /// </summary> - private readonly ILibraryManager _libraryManager; - private readonly IItemRepository _itemRepo; - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - - public AlbumsService( - ILogger<AlbumsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - IUserDataManager userDataRepository, - ILibraryManager libraryManager, - IItemRepository itemRepo, - IDtoService dtoService, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _userDataRepository = userDataRepository; - _libraryManager = libraryManager; - _itemRepo = itemRepo; - _dtoService = dtoService; - _authContext = authContext; - } - - public object Get(GetSimilarArtists request) - { - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = SimilarItemsHelper.GetSimilarItemsResult( - dtoOptions, - _userManager, - _itemRepo, - _libraryManager, - _userDataRepository, - _dtoService, - request, new[] { typeof(MusicArtist) }, - SimilarItemsHelper.GetSimiliarityScore); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetSimilarAlbums request) - { - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = SimilarItemsHelper.GetSimilarItemsResult( - dtoOptions, - _userManager, - _itemRepo, - _libraryManager, - _userDataRepository, - _dtoService, - request, new[] { typeof(MusicAlbum) }, - GetAlbumSimilarityScore); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the album similarity score. - /// </summary> - /// <param name="item1">The item1.</param> - /// <param name="item1People">The item1 people.</param> - /// <param name="allPeople">All people.</param> - /// <param name="item2">The item2.</param> - /// <returns>System.Int32.</returns> - private int GetAlbumSimilarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> 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<string>( - album2.GetAllArtists().DistinctNames(), - StringComparer.OrdinalIgnoreCase); - - return points + artists1.Where(artists2.Contains).Sum(i => 5); - } - } -} diff --git a/MediaBrowser.Api/Music/InstantMixService.cs b/MediaBrowser.Api/Music/InstantMixService.cs deleted file mode 100644 index ebd3eb64a..000000000 --- a/MediaBrowser.Api/Music/InstantMixService.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Data.Entities; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Playlists; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Music -{ - [Route("/Songs/{Id}/InstantMix", "GET", Summary = "Creates an instant playlist based on a given song")] - public class GetInstantMixFromSong : BaseGetSimilarItemsFromItem - { - } - - [Route("/Albums/{Id}/InstantMix", "GET", Summary = "Creates an instant playlist based on a given album")] - public class GetInstantMixFromAlbum : BaseGetSimilarItemsFromItem - { - } - - [Route("/Playlists/{Id}/InstantMix", "GET", Summary = "Creates an instant playlist based on a given playlist")] - public class GetInstantMixFromPlaylist : BaseGetSimilarItemsFromItem - { - } - - [Route("/MusicGenres/{Name}/InstantMix", "GET", Summary = "Creates an instant playlist based on a music genre")] - public class GetInstantMixFromMusicGenre : BaseGetSimilarItems - { - [ApiMember(Name = "Name", Description = "The genre name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - } - - [Route("/Artists/InstantMix", "GET", Summary = "Creates an instant playlist based on a given artist")] - public class GetInstantMixFromArtistId : BaseGetSimilarItems - { - [ApiMember(Name = "Id", Description = "The artist Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/MusicGenres/InstantMix", "GET", Summary = "Creates an instant playlist based on a music genre")] - public class GetInstantMixFromMusicGenreId : BaseGetSimilarItems - { - [ApiMember(Name = "Id", Description = "The genre Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Items/{Id}/InstantMix", "GET", Summary = "Creates an instant playlist based on a given item")] - public class GetInstantMixFromItem : BaseGetSimilarItemsFromItem - { - } - - [Authenticated] - public class InstantMixService : BaseApiService - { - private readonly IUserManager _userManager; - - private readonly IDtoService _dtoService; - private readonly ILibraryManager _libraryManager; - private readonly IMusicManager _musicManager; - private readonly IAuthorizationContext _authContext; - - public InstantMixService( - ILogger<InstantMixService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - IDtoService dtoService, - IMusicManager musicManager, - ILibraryManager libraryManager, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _dtoService = dtoService; - _musicManager = musicManager; - _libraryManager = libraryManager; - _authContext = authContext; - } - - public object Get(GetInstantMixFromItem request) - { - var item = _libraryManager.GetItemById(request.Id); - - var user = _userManager.GetUserById(request.UserId); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - - return GetResult(items, user, request, dtoOptions); - } - - public object Get(GetInstantMixFromArtistId request) - { - var item = _libraryManager.GetItemById(request.Id); - - var user = _userManager.GetUserById(request.UserId); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - - return GetResult(items, user, request, dtoOptions); - } - - public object Get(GetInstantMixFromMusicGenreId request) - { - var item = _libraryManager.GetItemById(request.Id); - - var user = _userManager.GetUserById(request.UserId); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - - return GetResult(items, user, request, dtoOptions); - } - - public object Get(GetInstantMixFromSong request) - { - var item = _libraryManager.GetItemById(request.Id); - - var user = _userManager.GetUserById(request.UserId); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - - return GetResult(items, user, request, dtoOptions); - } - - public object Get(GetInstantMixFromAlbum request) - { - var album = _libraryManager.GetItemById(request.Id); - - var user = _userManager.GetUserById(request.UserId); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); - - return GetResult(items, user, request, dtoOptions); - } - - public object Get(GetInstantMixFromPlaylist request) - { - var playlist = (Playlist)_libraryManager.GetItemById(request.Id); - - var user = _userManager.GetUserById(request.UserId); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); - - return GetResult(items, user, request, dtoOptions); - } - - public object Get(GetInstantMixFromMusicGenre request) - { - var user = _userManager.GetUserById(request.UserId); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var items = _musicManager.GetInstantMixFromGenres(new[] { request.Name }, user, dtoOptions); - - return GetResult(items, user, request, dtoOptions); - } - - private object GetResult(List<BaseItem> items, User user, BaseGetSimilarItems request, DtoOptions dtoOptions) - { - var list = items; - - var result = new QueryResult<BaseItemDto> - { - TotalRecordCount = list.Count - }; - - if (request.Limit.HasValue) - { - list = list.Take(request.Limit.Value).ToList(); - } - - var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); - - result.Items = returnList; - - return result; - } - } -} diff --git a/MediaBrowser.Api/PackageService.cs b/MediaBrowser.Api/PackageService.cs deleted file mode 100644 index a84556fcc..000000000 --- a/MediaBrowser.Api/PackageService.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Updates; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.Updates; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Repositories", "GET", Summary = "Gets all package repositories")] - [Authenticated] - public class GetRepositories : IReturnVoid - { - } - - [Route("/Repositories", "POST", Summary = "Sets the enabled and existing package repositories")] - [Authenticated] - public class SetRepositories : List<RepositoryInfo>, IReturnVoid - { - } - - /// <summary> - /// Class GetPackage. - /// </summary> - [Route("/Packages/{Name}", "GET", Summary = "Gets a package, by name or assembly guid")] - [Authenticated] - public class GetPackage : IReturn<PackageInfo> - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "The name of the package", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "AssemblyGuid", Description = "The guid of the associated assembly", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string AssemblyGuid { get; set; } - } - - /// <summary> - /// Class GetPackages. - /// </summary> - [Route("/Packages", "GET", Summary = "Gets available packages")] - [Authenticated] - public class GetPackages : IReturn<PackageInfo[]> - { - } - - /// <summary> - /// Class InstallPackage. - /// </summary> - [Route("/Packages/Installed/{Name}", "POST", Summary = "Installs a package")] - [Authenticated(Roles = "Admin")] - public class InstallPackage : IReturnVoid - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "Package name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Name { get; set; } - - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "AssemblyGuid", Description = "Guid of the associated assembly", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string AssemblyGuid { get; set; } - - /// <summary> - /// Gets or sets the version. - /// </summary> - /// <value>The version.</value> - [ApiMember(Name = "Version", Description = "Optional version. Defaults to latest version.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Version { get; set; } - } - - /// <summary> - /// Class CancelPackageInstallation. - /// </summary> - [Route("/Packages/Installing/{Id}", "DELETE", Summary = "Cancels a package installation")] - [Authenticated(Roles = "Admin")] - public class CancelPackageInstallation : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Installation Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - /// <summary> - /// Class PackageService. - /// </summary> - public class PackageService : BaseApiService - { - private readonly IInstallationManager _installationManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - - public PackageService( - ILogger<PackageService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IInstallationManager installationManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _installationManager = installationManager; - _serverConfigurationManager = serverConfigurationManager; - } - - public object Get(GetRepositories request) - { - var result = _serverConfigurationManager.Configuration.PluginRepositories; - return ToOptimizedResult(result); - } - - public void Post(SetRepositories request) - { - _serverConfigurationManager.Configuration.PluginRepositories = request; - _serverConfigurationManager.SaveConfiguration(); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetPackage request) - { - var packages = _installationManager.GetAvailablePackages().GetAwaiter().GetResult(); - var result = _installationManager.FilterPackages( - packages, - request.Name, - string.IsNullOrEmpty(request.AssemblyGuid) ? default : Guid.Parse(request.AssemblyGuid)).FirstOrDefault(); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Get(GetPackages request) - { - IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - - return ToOptimizedResult(packages.ToArray()); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <exception cref="ResourceNotFoundException"></exception> - public async Task Post(InstallPackage request) - { - var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - var package = _installationManager.GetCompatibleVersions( - packages, - request.Name, - string.IsNullOrEmpty(request.AssemblyGuid) ? Guid.Empty : Guid.Parse(request.AssemblyGuid), - string.IsNullOrEmpty(request.Version) ? null : Version.Parse(request.Version)).FirstOrDefault(); - - if (package == null) - { - throw new ResourceNotFoundException( - string.Format( - CultureInfo.InvariantCulture, - "Package not found: {0}", - request.Name)); - } - - await _installationManager.InstallPackage(package); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Delete(CancelPackageInstallation request) - { - _installationManager.CancelInstallation(new Guid(request.Id)); - } - } -} diff --git a/MediaBrowser.Api/Playback/BaseStreamingService.cs b/MediaBrowser.Api/Playback/BaseStreamingService.cs deleted file mode 100644 index 84ed5dcac..000000000 --- a/MediaBrowser.Api/Playback/BaseStreamingService.cs +++ /dev/null @@ -1,1008 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Playback -{ - /// <summary> - /// Class BaseStreamingService. - /// </summary> - public abstract class BaseStreamingService : BaseApiService - { - protected virtual bool EnableOutputInSubFolder => false; - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - protected IUserManager UserManager { get; private set; } - - /// <summary> - /// Gets or sets the library manager. - /// </summary> - /// <value>The library manager.</value> - protected ILibraryManager LibraryManager { get; private set; } - - /// <summary> - /// Gets or sets the iso manager. - /// </summary> - /// <value>The iso manager.</value> - protected IIsoManager IsoManager { get; private set; } - - /// <summary> - /// Gets or sets the media encoder. - /// </summary> - /// <value>The media encoder.</value> - protected IMediaEncoder MediaEncoder { get; private set; } - - protected IFileSystem FileSystem { get; private set; } - - protected IDlnaManager DlnaManager { get; private set; } - - protected IDeviceManager DeviceManager { get; private set; } - - protected IMediaSourceManager MediaSourceManager { get; private set; } - - protected IJsonSerializer JsonSerializer { get; private set; } - - protected IAuthorizationContext AuthorizationContext { get; private set; } - - protected EncodingHelper EncodingHelper { get; set; } - - /// <summary> - /// Gets the type of the transcoding job. - /// </summary> - /// <value>The type of the transcoding job.</value> - protected abstract TranscodingJobType TranscodingJobType { get; } - - /// <summary> - /// Initializes a new instance of the <see cref="BaseStreamingService" /> class. - /// </summary> - protected BaseStreamingService( - ILogger<BaseStreamingService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IIsoManager isoManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - IMediaSourceManager mediaSourceManager, - IJsonSerializer jsonSerializer, - IAuthorizationContext authorizationContext, - EncodingHelper encodingHelper) - : base(logger, serverConfigurationManager, httpResultFactory) - { - UserManager = userManager; - LibraryManager = libraryManager; - IsoManager = isoManager; - MediaEncoder = mediaEncoder; - FileSystem = fileSystem; - DlnaManager = dlnaManager; - DeviceManager = deviceManager; - MediaSourceManager = mediaSourceManager; - JsonSerializer = jsonSerializer; - AuthorizationContext = authorizationContext; - - EncodingHelper = encodingHelper; - } - - /// <summary> - /// Gets the command line arguments. - /// </summary> - protected abstract string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding); - - /// <summary> - /// Gets the output file extension. - /// </summary> - /// <param name="state">The state.</param> - /// <returns>System.String.</returns> - protected virtual string GetOutputFileExtension(StreamState state) - { - return Path.GetExtension(state.RequestedUrl); - } - - /// <summary> - /// Gets the output file path. - /// </summary> - private string GetOutputFilePath(StreamState state, EncodingOptions encodingOptions, string outputFileExtension) - { - var data = $"{state.MediaPath}-{state.UserAgent}-{state.Request.DeviceId}-{state.Request.PlaySessionId}"; - - var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture); - var ext = outputFileExtension?.ToLowerInvariant(); - var folder = ServerConfigurationManager.GetTranscodePath(); - - return EnableOutputInSubFolder - ? Path.Combine(folder, filename, filename + ext) - : Path.Combine(folder, filename + ext); - } - - protected virtual string GetDefaultEncoderPreset() - { - return "superfast"; - } - - private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) - { - if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && IsoManager.CanMount(state.MediaPath)) - { - state.IsoMount = await IsoManager.Mount(state.MediaPath, cancellationTokenSource.Token).ConfigureAwait(false); - } - - if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) - { - var liveStreamResponse = await MediaSourceManager.OpenLiveStream(new LiveStreamRequest - { - OpenToken = state.MediaSource.OpenToken - }, cancellationTokenSource.Token).ConfigureAwait(false); - - EncodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl); - - if (state.VideoRequest != null) - { - EncodingHelper.TryStreamCopy(state); - } - } - - if (state.MediaSource.BufferMs.HasValue) - { - await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); - } - } - - /// <summary> - /// Starts the FFMPEG. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="outputPath">The output path.</param> - /// <param name="cancellationTokenSource">The cancellation token source.</param> - /// <param name="workingDirectory">The working directory.</param> - /// <returns>Task.</returns> - protected async Task<TranscodingJob> StartFfMpeg( - StreamState state, - string outputPath, - CancellationTokenSource cancellationTokenSource, - string workingDirectory = null) - { - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); - - await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); - - if (state.VideoRequest != null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - var auth = AuthorizationContext.GetAuthorizationInfo(Request); - if (auth.User != null && !auth.User.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) - { - ApiEntryPoint.Instance.OnTranscodeFailedToStart(outputPath, TranscodingJobType, state); - - throw new ArgumentException("User does not have access to video transcoding"); - } - } - - var encodingOptions = ServerConfigurationManager.GetEncodingOptions(); - - var process = new Process() - { - StartInfo = new ProcessStartInfo() - { - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - UseShellExecute = false, - - // Must consume both stdout and stderr or deadlocks may occur - // RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - - FileName = MediaEncoder.EncoderPath, - Arguments = GetCommandLineArguments(outputPath, encodingOptions, state, true), - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory, - - ErrorDialog = false - }, - EnableRaisingEvents = true - }; - - var transcodingJob = ApiEntryPoint.Instance.OnTranscodeBeginning(outputPath, - state.Request.PlaySessionId, - state.MediaSource.LiveStreamId, - Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), - TranscodingJobType, - process, - state.Request.DeviceId, - state, - cancellationTokenSource); - - var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; - Logger.LogInformation(commandLineLogMessage); - - var logFilePrefix = "ffmpeg-transcode"; - if (state.VideoRequest != null - && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) - ? "ffmpeg-remux" : "ffmpeg-directstream"; - } - - var logFilePath = Path.Combine(ServerConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt"); - - // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); - - var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(Request.AbsoluteUri + Environment.NewLine + Environment.NewLine + JsonSerializer.SerializeToString(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); - await logStream.WriteAsync(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length, cancellationTokenSource.Token).ConfigureAwait(false); - - process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); - - try - { - process.Start(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error starting ffmpeg"); - - ApiEntryPoint.Instance.OnTranscodeFailedToStart(outputPath, TranscodingJobType, state); - - throw; - } - - Logger.LogDebug("Launched ffmpeg process"); - state.TranscodingJob = transcodingJob; - - // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback - _ = new JobLogger(Logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); - - // Wait for the file to exist before proceeeding - var ffmpegTargetFile = state.WaitForPath ?? outputPath; - Logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); - while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) - { - await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); - } - - Logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile); - - if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) - { - await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); - - if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) - { - await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); - } - } - - if (!transcodingJob.HasExited) - { - StartThrottler(state, transcodingJob); - } - - Logger.LogDebug("StartFfMpeg() finished successfully"); - - return transcodingJob; - } - - private void StartThrottler(StreamState state, TranscodingJob transcodingJob) - { - if (EnableThrottling(state)) - { - transcodingJob.TranscodingThrottler = state.TranscodingThrottler = new TranscodingThrottler(transcodingJob, Logger, ServerConfigurationManager, FileSystem); - state.TranscodingThrottler.Start(); - } - } - - private bool EnableThrottling(StreamState state) - { - var encodingOptions = ServerConfigurationManager.GetEncodingOptions(); - - // enable throttling when NOT using hardware acceleration - if (string.IsNullOrEmpty(encodingOptions.HardwareAccelerationType)) - { - return state.InputProtocol == MediaProtocol.File && - state.RunTimeTicks.HasValue && - state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && - state.IsInputVideo && - state.VideoType == VideoType.VideoFile && - !EncodingHelper.IsCopyCodec(state.OutputVideoCodec); - } - - return false; - } - - /// <summary> - /// Processes the exited. - /// </summary> - /// <param name="process">The process.</param> - /// <param name="job">The job.</param> - /// <param name="state">The state.</param> - private void OnFfMpegProcessExited(Process process, TranscodingJob job, StreamState state) - { - if (job != null) - { - job.HasExited = true; - } - - Logger.LogDebug("Disposing stream resources"); - state.Dispose(); - - if (process.ExitCode == 0) - { - Logger.LogInformation("FFMpeg exited with code 0"); - } - else - { - Logger.LogError("FFMpeg exited with code {0}", process.ExitCode); - } - - process.Dispose(); - } - - /// <summary> - /// Parses the parameters. - /// </summary> - /// <param name="request">The request.</param> - private void ParseParams(StreamRequest request) - { - var vals = request.Params.Split(';'); - - var videoRequest = request as VideoStreamRequest; - - for (var i = 0; i < vals.Length; i++) - { - var val = vals[i]; - - if (string.IsNullOrWhiteSpace(val)) - { - continue; - } - - switch (i) - { - case 0: - request.DeviceProfileId = val; - break; - case 1: - request.DeviceId = val; - break; - case 2: - request.MediaSourceId = val; - break; - case 3: - request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - break; - case 4: - if (videoRequest != null) - { - videoRequest.VideoCodec = val; - } - - break; - case 5: - request.AudioCodec = val; - break; - case 6: - if (videoRequest != null) - { - videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); - } - - break; - case 7: - if (videoRequest != null) - { - videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); - } - - break; - case 8: - if (videoRequest != null) - { - videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture); - } - - break; - case 9: - request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture); - break; - case 10: - request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); - break; - case 11: - if (videoRequest != null) - { - videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture); - } - - break; - case 12: - if (videoRequest != null) - { - videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture); - } - - break; - case 13: - if (videoRequest != null) - { - videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture); - } - - break; - case 14: - request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); - break; - case 15: - if (videoRequest != null) - { - videoRequest.Level = val; - } - - break; - case 16: - if (videoRequest != null) - { - videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture); - } - - break; - case 17: - if (videoRequest != null) - { - videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture); - } - - break; - case 18: - if (videoRequest != null) - { - videoRequest.Profile = val; - } - - break; - case 19: - // cabac no longer used - break; - case 20: - request.PlaySessionId = val; - break; - case 21: - // api_key - break; - case 22: - request.LiveStreamId = val; - break; - case 23: - // Duplicating ItemId because of MediaMonkey - break; - case 24: - if (videoRequest != null) - { - videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } - - break; - case 25: - if (!string.IsNullOrWhiteSpace(val) && videoRequest != null) - { - if (Enum.TryParse(val, out SubtitleDeliveryMethod method)) - { - videoRequest.SubtitleMethod = method; - } - } - - break; - case 26: - request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); - break; - case 27: - if (videoRequest != null) - { - videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } - - break; - case 28: - request.Tag = val; - break; - case 29: - if (videoRequest != null) - { - videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } - - break; - case 30: - request.SubtitleCodec = val; - break; - case 31: - if (videoRequest != null) - { - videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } - - break; - case 32: - if (videoRequest != null) - { - videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } - - break; - case 33: - request.TranscodeReasons = val; - break; - } - } - } - - /// <summary> - /// Parses query parameters as StreamOptions. - /// </summary> - /// <param name="request">The stream request.</param> - private void ParseStreamOptions(StreamRequest request) - { - foreach (var param in Request.QueryString) - { - if (char.IsLower(param.Key[0])) - { - // This was probably not parsed initially and should be a StreamOptions - // TODO: This should be incorporated either in the lower framework for parsing requests - // or the generated URL should correctly serialize it - request.StreamOptions[param.Key] = param.Value; - } - } - } - - /// <summary> - /// Parses the dlna headers. - /// </summary> - /// <param name="request">The request.</param> - private void ParseDlnaHeaders(StreamRequest request) - { - if (!request.StartTimeTicks.HasValue) - { - var timeSeek = GetHeader("TimeSeekRange.dlna.org"); - - request.StartTimeTicks = ParseTimeSeekHeader(timeSeek); - } - } - - /// <summary> - /// Parses the time seek header. - /// </summary> - private long? ParseTimeSeekHeader(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - const string Npt = "npt="; - if (!value.StartsWith(Npt, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("Invalid timeseek header"); - } - - int index = value.IndexOf('-'); - value = index == -1 - ? value.Substring(Npt.Length) - : value.Substring(Npt.Length, index - Npt.Length); - - if (value.IndexOf(':') == -1) - { - // Parses npt times in the format of '417.33' - if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) - { - return TimeSpan.FromSeconds(seconds).Ticks; - } - - throw new ArgumentException("Invalid timeseek header"); - } - - // Parses npt times in the format of '10:19:25.7' - var tokens = value.Split(new[] { ':' }, 3); - double secondsSum = 0; - var timeFactor = 3600; - - foreach (var time in tokens) - { - if (double.TryParse(time, NumberStyles.Any, CultureInfo.InvariantCulture, out var digit)) - { - secondsSum += digit * timeFactor; - } - else - { - throw new ArgumentException("Invalid timeseek header"); - } - - timeFactor /= 60; - } - - return TimeSpan.FromSeconds(secondsSum).Ticks; - } - - /// <summary> - /// Gets the state. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>StreamState.</returns> - protected async Task<StreamState> GetState(StreamRequest request, CancellationToken cancellationToken) - { - ParseDlnaHeaders(request); - - if (!string.IsNullOrWhiteSpace(request.Params)) - { - ParseParams(request); - } - - ParseStreamOptions(request); - - var url = Request.PathInfo; - - if (string.IsNullOrEmpty(request.AudioCodec)) - { - request.AudioCodec = EncodingHelper.InferAudioCodec(url); - } - - var enableDlnaHeaders = !string.IsNullOrWhiteSpace(request.Params) || - string.Equals(GetHeader("GetContentFeatures.DLNA.ORG"), "1", StringComparison.OrdinalIgnoreCase); - - var state = new StreamState(MediaSourceManager, TranscodingJobType) - { - Request = request, - RequestedUrl = url, - UserAgent = Request.UserAgent, - EnableDlnaHeaders = enableDlnaHeaders - }; - - var auth = AuthorizationContext.GetAuthorizationInfo(Request); - if (!auth.UserId.Equals(Guid.Empty)) - { - state.User = UserManager.GetUserById(auth.UserId); - } - - // if ((Request.UserAgent ?? string.Empty).IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 || - // (Request.UserAgent ?? string.Empty).IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 || - // (Request.UserAgent ?? string.Empty).IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) - //{ - // state.SegmentLength = 6; - //} - - if (state.VideoRequest != null && !string.IsNullOrWhiteSpace(state.VideoRequest.VideoCodec)) - { - state.SupportedVideoCodecs = state.VideoRequest.VideoCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); - state.VideoRequest.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); - } - - if (!string.IsNullOrWhiteSpace(request.AudioCodec)) - { - state.SupportedAudioCodecs = request.AudioCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); - state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => MediaEncoder.CanEncodeToAudioCodec(i)) - ?? state.SupportedAudioCodecs.FirstOrDefault(); - } - - if (!string.IsNullOrWhiteSpace(request.SubtitleCodec)) - { - state.SupportedSubtitleCodecs = request.SubtitleCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); - state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(i => MediaEncoder.CanEncodeToSubtitleCodec(i)) - ?? state.SupportedSubtitleCodecs.FirstOrDefault(); - } - - var item = LibraryManager.GetItemById(request.Id); - - state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); - - // var primaryImage = item.GetImageInfo(ImageType.Primary, 0) ?? - // item.Parents.Select(i => i.GetImageInfo(ImageType.Primary, 0)).FirstOrDefault(i => i != null); - // if (primaryImage != null) - //{ - // state.AlbumCoverPath = primaryImage.Path; - //} - - MediaSourceInfo mediaSource = null; - if (string.IsNullOrWhiteSpace(request.LiveStreamId)) - { - var currentJob = !string.IsNullOrWhiteSpace(request.PlaySessionId) ? - ApiEntryPoint.Instance.GetTranscodingJob(request.PlaySessionId) - : null; - - if (currentJob != null) - { - mediaSource = currentJob.MediaSource; - } - - if (mediaSource == null) - { - var mediaSources = await MediaSourceManager.GetPlaybackMediaSources(LibraryManager.GetItemById(request.Id), null, false, false, cancellationToken).ConfigureAwait(false); - - mediaSource = string.IsNullOrEmpty(request.MediaSourceId) - ? mediaSources[0] - : mediaSources.Find(i => string.Equals(i.Id, request.MediaSourceId)); - - if (mediaSource == null && Guid.Parse(request.MediaSourceId) == request.Id) - { - mediaSource = mediaSources[0]; - } - } - } - else - { - var liveStreamInfo = await MediaSourceManager.GetLiveStreamWithDirectStreamProvider(request.LiveStreamId, cancellationToken).ConfigureAwait(false); - mediaSource = liveStreamInfo.Item1; - state.DirectStreamProvider = liveStreamInfo.Item2; - } - - var videoRequest = request as VideoStreamRequest; - - EncodingHelper.AttachMediaSourceInfo(state, mediaSource, url); - - var container = Path.GetExtension(state.RequestedUrl); - - if (string.IsNullOrEmpty(container)) - { - container = request.Container; - } - - if (string.IsNullOrEmpty(container)) - { - container = request.Static ? - StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, state.MediaPath, null, DlnaProfileType.Audio) : - GetOutputFileExtension(state); - } - - state.OutputContainer = (container ?? string.Empty).TrimStart('.'); - - state.OutputAudioBitrate = EncodingHelper.GetAudioBitrateParam(state.Request, state.AudioStream); - - state.OutputAudioCodec = state.Request.AudioCodec; - - state.OutputAudioChannels = EncodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); - - if (videoRequest != null) - { - state.OutputVideoCodec = state.VideoRequest.VideoCodec; - state.OutputVideoBitrate = EncodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - - if (videoRequest != null) - { - EncodingHelper.TryStreamCopy(state); - } - - if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - var resolution = ResolutionNormalizer.Normalize( - state.VideoStream?.BitRate, - state.VideoStream?.Width, - state.VideoStream?.Height, - state.OutputVideoBitrate.Value, - state.VideoStream?.Codec, - state.OutputVideoCodec, - videoRequest.MaxWidth, - videoRequest.MaxHeight); - - videoRequest.MaxWidth = resolution.MaxWidth; - videoRequest.MaxHeight = resolution.MaxHeight; - } - } - - ApplyDeviceProfileSettings(state); - - var ext = string.IsNullOrWhiteSpace(state.OutputContainer) - ? GetOutputFileExtension(state) - : ('.' + state.OutputContainer); - - var encodingOptions = ServerConfigurationManager.GetEncodingOptions(); - - state.OutputFilePath = GetOutputFilePath(state, encodingOptions, ext); - - return state; - } - - private void ApplyDeviceProfileSettings(StreamState state) - { - var headers = Request.Headers; - - if (!string.IsNullOrWhiteSpace(state.Request.DeviceProfileId)) - { - state.DeviceProfile = DlnaManager.GetProfile(state.Request.DeviceProfileId); - } - else if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) - { - var caps = DeviceManager.GetCapabilities(state.Request.DeviceId); - - state.DeviceProfile = caps == null ? DlnaManager.GetProfile(headers) : caps.DeviceProfile; - } - - var profile = state.DeviceProfile; - - if (profile == null) - { - // Don't use settings from the default profile. - // Only use a specific profile if it was requested. - return; - } - - var audioCodec = state.ActualOutputAudioCodec; - var videoCodec = state.ActualOutputVideoCodec; - - var mediaProfile = state.VideoRequest == null ? - profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth) : - profile.GetVideoMediaProfile(state.OutputContainer, - audioCodec, - videoCodec, - state.OutputWidth, - state.OutputHeight, - state.TargetVideoBitDepth, - state.OutputVideoBitrate, - state.TargetVideoProfile, - state.TargetVideoLevel, - state.TargetFramerate, - state.TargetPacketLength, - state.TargetTimestamp, - state.IsTargetAnamorphic, - state.IsTargetInterlaced, - state.TargetRefFrames, - state.TargetVideoStreamCount, - state.TargetAudioStreamCount, - state.TargetVideoCodecTag, - state.IsTargetAVC); - - if (mediaProfile != null) - { - state.MimeType = mediaProfile.MimeType; - } - - if (!state.Request.Static) - { - var transcodingProfile = state.VideoRequest == null ? - profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : - profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec); - - if (transcodingProfile != null) - { - state.EstimateContentLength = transcodingProfile.EstimateContentLength; - // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; - state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; - - if (state.VideoRequest != null) - { - state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps; - state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; - } - } - } - } - - /// <summary> - /// Adds the dlna headers. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="responseHeaders">The response headers.</param> - /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> - protected void AddDlnaHeaders(StreamState state, IDictionary<string, string> responseHeaders, bool isStaticallyStreamed) - { - if (!state.EnableDlnaHeaders) - { - return; - } - - var profile = state.DeviceProfile; - - var transferMode = GetHeader("transferMode.dlna.org"); - responseHeaders["transferMode.dlna.org"] = string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode; - responseHeaders["realTimeInfo.dlna.org"] = "DLNA.ORG_TLAG=*"; - - if (state.RunTimeTicks.HasValue) - { - if (string.Equals(GetHeader("getMediaInfo.sec"), "1", StringComparison.OrdinalIgnoreCase)) - { - var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds; - responseHeaders["MediaInfo.sec"] = string.Format( - CultureInfo.InvariantCulture, - "SEC_Duration={0};", - Convert.ToInt32(ms)); - } - - if (!isStaticallyStreamed && profile != null) - { - AddTimeSeekResponseHeaders(state, responseHeaders); - } - } - - if (profile == null) - { - profile = DlnaManager.GetDefaultProfile(); - } - - var audioCodec = state.ActualOutputAudioCodec; - - if (state.VideoRequest == null) - { - responseHeaders["contentFeatures.dlna.org"] = new ContentFeatureBuilder(profile).BuildAudioHeader( - state.OutputContainer, - audioCodec, - state.OutputAudioBitrate, - state.OutputAudioSampleRate, - state.OutputAudioChannels, - state.OutputAudioBitDepth, - isStaticallyStreamed, - state.RunTimeTicks, - state.TranscodeSeekInfo); - } - else - { - var videoCodec = state.ActualOutputVideoCodec; - - responseHeaders["contentFeatures.dlna.org"] = new ContentFeatureBuilder(profile).BuildVideoHeader( - state.OutputContainer, - videoCodec, - audioCodec, - state.OutputWidth, - state.OutputHeight, - state.TargetVideoBitDepth, - state.OutputVideoBitrate, - state.TargetTimestamp, - isStaticallyStreamed, - state.RunTimeTicks, - state.TargetVideoProfile, - state.TargetVideoLevel, - state.TargetFramerate, - state.TargetPacketLength, - state.TranscodeSeekInfo, - state.IsTargetAnamorphic, - state.IsTargetInterlaced, - state.TargetRefFrames, - state.TargetVideoStreamCount, - state.TargetAudioStreamCount, - state.TargetVideoCodecTag, - state.IsTargetAVC).FirstOrDefault() ?? string.Empty; - } - } - - private void AddTimeSeekResponseHeaders(StreamState state, IDictionary<string, string> responseHeaders) - { - var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); - var startSeconds = TimeSpan.FromTicks(state.Request.StartTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); - - responseHeaders["TimeSeekRange.dlna.org"] = string.Format( - CultureInfo.InvariantCulture, - "npt={0}-{1}/{1}", - startSeconds, - runtimeSeconds); - responseHeaders["X-AvailableSeekRange"] = string.Format( - CultureInfo.InvariantCulture, - "1 npt={0}-{1}", - startSeconds, - runtimeSeconds); - } - } -} diff --git a/MediaBrowser.Api/Playback/Hls/BaseHlsService.cs b/MediaBrowser.Api/Playback/Hls/BaseHlsService.cs deleted file mode 100644 index 418cd92b3..000000000 --- a/MediaBrowser.Api/Playback/Hls/BaseHlsService.cs +++ /dev/null @@ -1,343 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Playback.Hls -{ - /// <summary> - /// Class BaseHlsService. - /// </summary> - public abstract class BaseHlsService : BaseStreamingService - { - public BaseHlsService( - ILogger<BaseHlsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IIsoManager isoManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - IMediaSourceManager mediaSourceManager, - IJsonSerializer jsonSerializer, - IAuthorizationContext authorizationContext, - EncodingHelper encodingHelper) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - isoManager, - mediaEncoder, - fileSystem, - dlnaManager, - deviceManager, - mediaSourceManager, - jsonSerializer, - authorizationContext, - encodingHelper) - { - } - - /// <summary> - /// Gets the audio arguments. - /// </summary> - protected abstract string GetAudioArguments(StreamState state, EncodingOptions encodingOptions); - - /// <summary> - /// Gets the video arguments. - /// </summary> - protected abstract string GetVideoArguments(StreamState state, EncodingOptions encodingOptions); - - /// <summary> - /// Gets the segment file extension. - /// </summary> - protected string GetSegmentFileExtension(StreamRequest request) - { - var segmentContainer = request.SegmentContainer; - if (!string.IsNullOrWhiteSpace(segmentContainer)) - { - return "." + segmentContainer; - } - - return ".ts"; - } - - /// <summary> - /// Gets the type of the transcoding job. - /// </summary> - /// <value>The type of the transcoding job.</value> - protected override TranscodingJobType TranscodingJobType => TranscodingJobType.Hls; - - /// <summary> - /// Processes the request async. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="isLive">if set to <c>true</c> [is live].</param> - /// <returns>Task{System.Object}.</returns> - /// <exception cref="ArgumentException">A video bitrate is required - /// or - /// An audio bitrate is required</exception> - protected async Task<object> ProcessRequestAsync(StreamRequest request, bool isLive) - { - var cancellationTokenSource = new CancellationTokenSource(); - - var state = await GetState(request, cancellationTokenSource.Token).ConfigureAwait(false); - - TranscodingJob job = null; - var playlist = state.OutputFilePath; - - if (!File.Exists(playlist)) - { - var transcodingLock = ApiEntryPoint.Instance.GetTranscodingLock(playlist); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); - try - { - if (!File.Exists(playlist)) - { - // If the playlist doesn't already exist, startup ffmpeg - try - { - job = await StartFfMpeg(state, playlist, cancellationTokenSource).ConfigureAwait(false); - job.IsLiveOutput = isLive; - } - catch - { - state.Dispose(); - throw; - } - - var minSegments = state.MinSegments; - if (minSegments > 0) - { - await WaitForMinimumSegmentCount(playlist, minSegments, cancellationTokenSource.Token).ConfigureAwait(false); - } - } - } - finally - { - transcodingLock.Release(); - } - } - - if (isLive) - { - job ??= ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlist, TranscodingJobType); - - if (job != null) - { - ApiEntryPoint.Instance.OnTranscodeEndRequest(job); - } - - return ResultFactory.GetResult(GetLivePlaylistText(playlist, state.SegmentLength), MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>()); - } - - var audioBitrate = state.OutputAudioBitrate ?? 0; - var videoBitrate = state.OutputVideoBitrate ?? 0; - - var baselineStreamBitrate = 64000; - - var playlistText = GetMasterPlaylistFileText(playlist, videoBitrate + audioBitrate, baselineStreamBitrate); - - job ??= ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlist, TranscodingJobType); - - if (job != null) - { - ApiEntryPoint.Instance.OnTranscodeEndRequest(job); - } - - return ResultFactory.GetResult(playlistText, MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>()); - } - - private string GetLivePlaylistText(string path, int segmentLength) - { - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream); - - var text = reader.ReadToEnd(); - - text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT"); - - var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture); - - text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); - // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); - - return text; - } - - private string GetMasterPlaylistFileText(string firstPlaylist, int bitrate, int baselineStreamBitrate) - { - var builder = new StringBuilder(); - - builder.AppendLine("#EXTM3U"); - - // Pad a little to satisfy the apple hls validator - var paddedBitrate = Convert.ToInt32(bitrate * 1.15); - - // Main stream - builder.AppendLine("#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=" + paddedBitrate.ToString(CultureInfo.InvariantCulture)); - var playlistUrl = "hls/" + Path.GetFileName(firstPlaylist).Replace(".m3u8", "/stream.m3u8"); - builder.AppendLine(playlistUrl); - - return builder.ToString(); - } - - protected virtual async Task WaitForMinimumSegmentCount(string playlist, int segmentCount, CancellationToken cancellationToken) - { - Logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist); - - while (!cancellationToken.IsCancellationRequested) - { - try - { - // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written - var fileStream = GetPlaylistFileStream(playlist); - await using (fileStream.ConfigureAwait(false)) - { - using var reader = new StreamReader(fileStream); - var count = 0; - - while (!reader.EndOfStream) - { - var line = await reader.ReadLineAsync().ConfigureAwait(false); - - if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) - { - count++; - if (count >= segmentCount) - { - Logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist); - return; - } - } - } - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - catch (IOException) - { - // May get an error if the file is locked - } - - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } - } - - protected Stream GetPlaylistFileStream(string path) - { - return new FileStream( - path, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite, - IODefaults.FileStreamBufferSize, - FileOptions.SequentialScan); - } - - protected override string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding) - { - var itsOffsetMs = 0; - - var itsOffset = itsOffsetMs == 0 ? string.Empty : string.Format("-itsoffset {0} ", TimeSpan.FromMilliseconds(itsOffsetMs).TotalSeconds.ToString(CultureInfo.InvariantCulture)); - - var videoCodec = EncodingHelper.GetVideoEncoder(state, encodingOptions); - - var threads = EncodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); - - var inputModifier = EncodingHelper.GetInputModifier(state, encodingOptions); - - // If isEncoding is true we're actually starting ffmpeg - var startNumberParam = isEncoding ? GetStartNumber(state).ToString(CultureInfo.InvariantCulture) : "0"; - - var baseUrlParam = string.Empty; - - if (state.Request is GetLiveHlsStream) - { - baseUrlParam = string.Format(" -hls_base_url \"{0}/\"", - "hls/" + Path.GetFileNameWithoutExtension(outputPath)); - } - - var useGenericSegmenter = true; - if (useGenericSegmenter) - { - var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request); - - var timeDeltaParam = string.Empty; - - var segmentFormat = GetSegmentFileExtension(state.Request).TrimStart('.'); - if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) - { - segmentFormat = "mpegts"; - } - - baseUrlParam = string.Format("\"{0}/\"", "hls/" + Path.GetFileNameWithoutExtension(outputPath)); - - return string.Format("{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {10} -individual_header_trailer 0 -segment_format {11} -segment_list_entry_prefix {12} -segment_list_type m3u8 -segment_start_number {7} -segment_list \"{8}\" -y \"{9}\"", - inputModifier, - EncodingHelper.GetInputArgument(state, encodingOptions), - threads, - EncodingHelper.GetMapArgs(state), - GetVideoArguments(state, encodingOptions), - GetAudioArguments(state, encodingOptions), - state.SegmentLength.ToString(CultureInfo.InvariantCulture), - startNumberParam, - outputPath, - outputTsArg, - timeDeltaParam, - segmentFormat, - baseUrlParam - ).Trim(); - } - - // add when stream copying? - // -avoid_negative_ts make_zero -fflags +genpts - - var args = string.Format("{0} {1} {2} -map_metadata -1 -map_chapters -1 -threads {3} {4} {5} -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero {6} -hls_time {7} -individual_header_trailer 0 -start_number {8} -hls_list_size {9}{10} -y \"{11}\"", - itsOffset, - inputModifier, - EncodingHelper.GetInputArgument(state, encodingOptions), - threads, - EncodingHelper.GetMapArgs(state), - GetVideoArguments(state, encodingOptions), - GetAudioArguments(state, encodingOptions), - state.SegmentLength.ToString(CultureInfo.InvariantCulture), - startNumberParam, - state.HlsListSize.ToString(CultureInfo.InvariantCulture), - baseUrlParam, - outputPath - ).Trim(); - - return args; - } - - protected override string GetDefaultEncoderPreset() - { - return "veryfast"; - } - - protected virtual int GetStartNumber(StreamState state) - { - return 0; - } - } -} diff --git a/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs b/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs deleted file mode 100644 index 6ff96ac56..000000000 --- a/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs +++ /dev/null @@ -1,1230 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using MimeTypes = MediaBrowser.Model.Net.MimeTypes; - -namespace MediaBrowser.Api.Playback.Hls -{ - /// <summary> - /// Options is needed for chromecast. Threw Head in there since it's related - /// </summary> - [Route("/Videos/{Id}/master.m3u8", "GET", Summary = "Gets a video stream using HTTP live streaming.")] - [Route("/Videos/{Id}/master.m3u8", "HEAD", Summary = "Gets a video stream using HTTP live streaming.")] - public class GetMasterHlsVideoPlaylist : VideoStreamRequest, IMasterHlsRequest - { - public bool EnableAdaptiveBitrateStreaming { get; set; } - - public GetMasterHlsVideoPlaylist() - { - EnableAdaptiveBitrateStreaming = true; - } - } - - [Route("/Audio/{Id}/master.m3u8", "GET", Summary = "Gets an audio stream using HTTP live streaming.")] - [Route("/Audio/{Id}/master.m3u8", "HEAD", Summary = "Gets an audio stream using HTTP live streaming.")] - public class GetMasterHlsAudioPlaylist : StreamRequest, IMasterHlsRequest - { - public bool EnableAdaptiveBitrateStreaming { get; set; } - - public GetMasterHlsAudioPlaylist() - { - EnableAdaptiveBitrateStreaming = true; - } - } - - public interface IMasterHlsRequest - { - bool EnableAdaptiveBitrateStreaming { get; set; } - } - - [Route("/Videos/{Id}/main.m3u8", "GET", Summary = "Gets a video stream using HTTP live streaming.")] - public class GetVariantHlsVideoPlaylist : VideoStreamRequest - { - } - - [Route("/Audio/{Id}/main.m3u8", "GET", Summary = "Gets an audio stream using HTTP live streaming.")] - public class GetVariantHlsAudioPlaylist : StreamRequest - { - } - - [Route("/Videos/{Id}/hls1/{PlaylistId}/{SegmentId}.{SegmentContainer}", "GET")] - public class GetHlsVideoSegment : VideoStreamRequest - { - public string PlaylistId { get; set; } - - /// <summary> - /// Gets or sets the segment id. - /// </summary> - /// <value>The segment id.</value> - public string SegmentId { get; set; } - } - - [Route("/Audio/{Id}/hls1/{PlaylistId}/{SegmentId}.{SegmentContainer}", "GET")] - public class GetHlsAudioSegment : StreamRequest - { - public string PlaylistId { get; set; } - - /// <summary> - /// Gets or sets the segment id. - /// </summary> - /// <value>The segment id.</value> - public string SegmentId { get; set; } - } - - [Authenticated] - public class DynamicHlsService : BaseHlsService - { - public DynamicHlsService( - ILogger<DynamicHlsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IIsoManager isoManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - IMediaSourceManager mediaSourceManager, - IJsonSerializer jsonSerializer, - IAuthorizationContext authorizationContext, - INetworkManager networkManager, - EncodingHelper encodingHelper) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - isoManager, - mediaEncoder, - fileSystem, - dlnaManager, - deviceManager, - mediaSourceManager, - jsonSerializer, - authorizationContext, - encodingHelper) - { - NetworkManager = networkManager; - } - - protected INetworkManager NetworkManager { get; private set; } - - public Task<object> Get(GetMasterHlsVideoPlaylist request) - { - return GetMasterPlaylistInternal(request, "GET"); - } - - public Task<object> Head(GetMasterHlsVideoPlaylist request) - { - return GetMasterPlaylistInternal(request, "HEAD"); - } - - public Task<object> Get(GetMasterHlsAudioPlaylist request) - { - return GetMasterPlaylistInternal(request, "GET"); - } - - public Task<object> Head(GetMasterHlsAudioPlaylist request) - { - return GetMasterPlaylistInternal(request, "HEAD"); - } - - public Task<object> Get(GetVariantHlsVideoPlaylist request) - { - return GetVariantPlaylistInternal(request, true, "main"); - } - - public Task<object> Get(GetVariantHlsAudioPlaylist request) - { - return GetVariantPlaylistInternal(request, false, "main"); - } - - public Task<object> Get(GetHlsVideoSegment request) - { - return GetDynamicSegment(request, request.SegmentId); - } - - public Task<object> Get(GetHlsAudioSegment request) - { - return GetDynamicSegment(request, request.SegmentId); - } - - private async Task<object> GetDynamicSegment(StreamRequest request, string segmentId) - { - if ((request.StartTimeTicks ?? 0) > 0) - { - throw new ArgumentException("StartTimeTicks is not allowed."); - } - - var cancellationTokenSource = new CancellationTokenSource(); - var cancellationToken = cancellationTokenSource.Token; - - var requestedIndex = int.Parse(segmentId, NumberStyles.Integer, CultureInfo.InvariantCulture); - - var state = await GetState(request, cancellationToken).ConfigureAwait(false); - - var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - - var segmentPath = GetSegmentPath(state, playlistPath, requestedIndex); - - var segmentExtension = GetSegmentFileExtension(state.Request); - - TranscodingJob job = null; - - if (File.Exists(segmentPath)) - { - job = ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - Logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, requestedIndex, job, cancellationToken).ConfigureAwait(false); - } - - var transcodingLock = ApiEntryPoint.Instance.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); - var released = false; - var startTranscoding = false; - - try - { - if (File.Exists(segmentPath)) - { - job = ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - transcodingLock.Release(); - released = true; - Logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, requestedIndex, job, cancellationToken).ConfigureAwait(false); - } - else - { - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - - if (currentTranscodingIndex == null) - { - Logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); - startTranscoding = true; - } - else if (requestedIndex < currentTranscodingIndex.Value) - { - Logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", requestedIndex, currentTranscodingIndex); - startTranscoding = true; - } - else if (requestedIndex - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) - { - Logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", requestedIndex - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, requestedIndex); - startTranscoding = true; - } - - if (startTranscoding) - { - // If the playlist doesn't already exist, startup ffmpeg - try - { - await ApiEntryPoint.Instance.KillTranscodingJobs(request.DeviceId, request.PlaySessionId, p => false); - - if (currentTranscodingIndex.HasValue) - { - DeleteLastFile(playlistPath, segmentExtension, 0); - } - - request.StartTimeTicks = GetStartPositionTicks(state, requestedIndex); - - state.WaitForPath = segmentPath; - job = await StartFfMpeg(state, playlistPath, cancellationTokenSource).ConfigureAwait(false); - } - catch - { - state.Dispose(); - throw; - } - - // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); - } - else - { - job = ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - if (job.TranscodingThrottler != null) - { - await job.TranscodingThrottler.UnpauseTranscoding(); - } - } - } - } - finally - { - if (!released) - { - transcodingLock.Release(); - } - } - - // Logger.LogInformation("waiting for {0}", segmentPath); - // while (!File.Exists(segmentPath)) - //{ - // await Task.Delay(50, cancellationToken).ConfigureAwait(false); - //} - - Logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, requestedIndex, job, cancellationToken).ConfigureAwait(false); - } - - private const int BufferSize = 81920; - - private long GetStartPositionTicks(StreamState state, int requestedIndex) - { - double startSeconds = 0; - var lengths = GetSegmentLengths(state); - - if (requestedIndex >= lengths.Length) - { - var msg = string.Format("Invalid segment index requested: {0} - Segment count: {1}", requestedIndex, lengths.Length); - throw new ArgumentException(msg); - } - - for (var i = 0; i < requestedIndex; i++) - { - startSeconds += lengths[i]; - } - - var position = TimeSpan.FromSeconds(startSeconds).Ticks; - return position; - } - - private long GetEndPositionTicks(StreamState state, int requestedIndex) - { - double startSeconds = 0; - var lengths = GetSegmentLengths(state); - - if (requestedIndex >= lengths.Length) - { - var msg = string.Format("Invalid segment index requested: {0} - Segment count: {1}", requestedIndex, lengths.Length); - throw new ArgumentException(msg); - } - - for (var i = 0; i <= requestedIndex; i++) - { - startSeconds += lengths[i]; - } - - var position = TimeSpan.FromSeconds(startSeconds).Ticks; - return position; - } - - private double[] GetSegmentLengths(StreamState state) - { - var result = new List<double>(); - - var ticks = state.RunTimeTicks ?? 0; - - var segmentLengthTicks = TimeSpan.FromSeconds(state.SegmentLength).Ticks; - - while (ticks > 0) - { - var length = ticks >= segmentLengthTicks ? segmentLengthTicks : ticks; - - result.Add(TimeSpan.FromTicks(length).TotalSeconds); - - ticks -= length; - } - - return result.ToArray(); - } - - public int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) - { - var job = ApiEntryPoint.Instance.GetTranscodingJob(playlist, TranscodingJobType); - - if (job == null || job.HasExited) - { - return null; - } - - var file = GetLastTranscodingFile(playlist, segmentExtension, FileSystem); - - if (file == null) - { - return null; - } - - var playlistFilename = Path.GetFileNameWithoutExtension(playlist); - - var indexString = Path.GetFileNameWithoutExtension(file.Name).AsSpan().Slice(playlistFilename.Length); - - return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture); - } - - private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) - { - var file = GetLastTranscodingFile(playlistPath, segmentExtension, FileSystem); - - if (file != null) - { - DeleteFile(file.FullName, retryCount); - } - } - - private void DeleteFile(string path, int retryCount) - { - if (retryCount >= 5) - { - return; - } - - Logger.LogDebug("Deleting partial HLS file {path}", path); - - try - { - FileSystem.DeleteFile(path); - } - catch (IOException ex) - { - Logger.LogError(ex, "Error deleting partial stream file(s) {path}", path); - - var task = Task.Delay(100); - Task.WaitAll(task); - DeleteFile(path, retryCount + 1); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error deleting partial stream file(s) {path}", path); - } - } - - private static FileSystemMetadata GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem) - { - var folder = Path.GetDirectoryName(playlist); - - var filePrefix = Path.GetFileNameWithoutExtension(playlist) ?? string.Empty; - - try - { - return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false) - .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(fileSystem.GetLastWriteTimeUtc) - .FirstOrDefault(); - } - catch (IOException) - { - return null; - } - } - - protected override int GetStartNumber(StreamState state) - { - return GetStartNumber(state.VideoRequest); - } - - private int GetStartNumber(VideoStreamRequest request) - { - var segmentId = "0"; - - if (request is GetHlsVideoSegment segmentRequest) - { - segmentId = segmentRequest.SegmentId; - } - - return int.Parse(segmentId, NumberStyles.Integer, CultureInfo.InvariantCulture); - } - - private string GetSegmentPath(StreamState state, string playlist, int index) - { - var folder = Path.GetDirectoryName(playlist); - - var filename = Path.GetFileNameWithoutExtension(playlist); - - return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + GetSegmentFileExtension(state.Request)); - } - - private async Task<object> GetSegmentResult(StreamState state, - string playlistPath, - string segmentPath, - string segmentExtension, - int segmentIndex, - TranscodingJob transcodingJob, - CancellationToken cancellationToken) - { - var segmentExists = File.Exists(segmentPath); - if (segmentExists) - { - if (transcodingJob != null && transcodingJob.HasExited) - { - // Transcoding job is over, so assume all existing files are ready - Logger.LogDebug("serving up {0} as transcode is over", segmentPath); - return await GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob).ConfigureAwait(false); - } - - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - - // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready - if (segmentIndex < currentTranscodingIndex) - { - Logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex); - return await GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob).ConfigureAwait(false); - } - } - - var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1); - if (transcodingJob != null) - { - while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited) - { - // To be considered ready, the segment file has to exist AND - // either the transcoding job should be done or next segment should also exist - if (segmentExists) - { - if (transcodingJob.HasExited || File.Exists(nextSegmentPath)) - { - Logger.LogDebug("serving up {0} as it deemed ready", segmentPath); - return await GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob).ConfigureAwait(false); - } - } - else - { - segmentExists = File.Exists(segmentPath); - if (segmentExists) - { - continue; // avoid unnecessary waiting if segment just became available - } - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - - if (!File.Exists(segmentPath)) - { - Logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath); - } - else - { - Logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath); - } - - cancellationToken.ThrowIfCancellationRequested(); - } - else - { - Logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath); - } - - return await GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob).ConfigureAwait(false); - } - - private Task<object> GetSegmentResult(StreamState state, string segmentPath, int index, TranscodingJob transcodingJob) - { - var segmentEndingPositionTicks = GetEndPositionTicks(state, index); - - return ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions - { - Path = segmentPath, - FileShare = FileShare.ReadWrite, - OnComplete = () => - { - Logger.LogDebug("finished serving {0}", segmentPath); - if (transcodingJob != null) - { - transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); - ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob); - } - } - }); - } - - private async Task<object> GetMasterPlaylistInternal(StreamRequest request, string method) - { - var state = await GetState(request, CancellationToken.None).ConfigureAwait(false); - - if (string.IsNullOrEmpty(request.MediaSourceId)) - { - throw new ArgumentException("MediaSourceId is required"); - } - - var playlistText = string.Empty; - - if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase)) - { - var audioBitrate = state.OutputAudioBitrate ?? 0; - var videoBitrate = state.OutputVideoBitrate ?? 0; - - playlistText = GetMasterPlaylistFileText(state, videoBitrate + audioBitrate); - } - - return ResultFactory.GetResult(playlistText, MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>()); - } - - private string GetMasterPlaylistFileText(StreamState state, int totalBitrate) - { - var builder = new StringBuilder(); - - builder.AppendLine("#EXTM3U"); - - var isLiveStream = state.IsSegmentedLiveStream; - - var queryStringIndex = Request.RawUrl.IndexOf('?'); - var queryString = queryStringIndex == -1 ? string.Empty : Request.RawUrl.Substring(queryStringIndex); - - // from universal audio service - if (queryString.IndexOf("SegmentContainer", StringComparison.OrdinalIgnoreCase) == -1 && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)) - { - queryString += "&SegmentContainer=" + state.Request.SegmentContainer; - } - // from universal audio service - if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) && queryString.IndexOf("TranscodeReasons=", StringComparison.OrdinalIgnoreCase) == -1) - { - queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; - } - - // Main stream - var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; - - playlistUrl += queryString; - - var request = state.Request; - - var subtitleStreams = state.MediaSource - .MediaStreams - .Where(i => i.IsTextSubtitleStream) - .ToList(); - - var subtitleGroup = subtitleStreams.Count > 0 && - request is GetMasterHlsVideoPlaylist && - (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest.EnableSubtitlesInManifest) ? - "subs" : - null; - - // If we're burning in subtitles then don't add additional subs to the manifest - if (state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) - { - subtitleGroup = null; - } - - if (!string.IsNullOrWhiteSpace(subtitleGroup)) - { - AddSubtitles(state, subtitleStreams, builder); - } - - AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); - - if (EnableAdaptiveBitrateStreaming(state, isLiveStream)) - { - var requestedVideoBitrate = state.VideoRequest == null ? 0 : state.VideoRequest.VideoBitRate ?? 0; - - // By default, vary by just 200k - var variation = GetBitrateVariation(totalBitrate); - - var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); - AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); - - variation *= 2; - newBitrate = totalBitrate - variation; - variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); - AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); - } - - return builder.ToString(); - } - - private string ReplaceBitrate(string url, int oldValue, int newValue) - { - return url.Replace( - "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), - "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), - StringComparison.OrdinalIgnoreCase); - } - - private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder) - { - var selectedIndex = state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; - - foreach (var stream in subtitles) - { - const string format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; - - var name = stream.DisplayTitle; - - var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index; - var isForced = stream.IsForced; - - var url = string.Format("{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", - state.Request.MediaSourceId, - stream.Index.ToString(CultureInfo.InvariantCulture), - 30.ToString(CultureInfo.InvariantCulture), - AuthorizationContext.GetAuthorizationInfo(Request).Token); - - var line = string.Format(format, - name, - isDefault ? "YES" : "NO", - isForced ? "YES" : "NO", - url, - stream.Language ?? "Unknown"); - - builder.AppendLine(line); - } - } - - private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream) - { - // Within the local network this will likely do more harm than good. - if (Request.IsLocal || NetworkManager.IsInLocalNetwork(Request.RemoteIp)) - { - return false; - } - - if (state.Request is IMasterHlsRequest request && !request.EnableAdaptiveBitrateStreaming) - { - return false; - } - - if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath)) - { - // Opening live streams is so slow it's not even worth it - return false; - } - - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - return false; - } - - if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec)) - { - return false; - } - - if (!state.IsOutputVideo) - { - return false; - } - - // Having problems in android - return false; - // return state.VideoRequest.VideoBitRate.HasValue; - } - - /// <summary> - /// Get the H.26X level of the output video stream. - /// </summary> - /// <param name="state">StreamState of the current stream.</param> - /// <returns>H.26X level of the output video stream.</returns> - private int? GetOutputVideoCodecLevel(StreamState state) - { - string levelString; - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && state.VideoStream.Level.HasValue) - { - levelString = state.VideoStream?.Level.ToString(); - } - else - { - levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec); - } - - if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) - { - return parsedLevel; - } - - return null; - } - - /// <summary> - /// Gets a formatted string of the output audio codec, for use in the CODECS field. - /// </summary> - /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> - /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> - /// <param name="state">StreamState of the current stream.</param> - /// <returns>Formatted audio codec string.</returns> - private string GetPlaylistAudioCodecs(StreamState state) - { - - if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) - { - string profile = state.GetRequestedProfiles("aac").FirstOrDefault(); - - return HlsCodecStringFactory.GetAACString(profile); - } - else if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringFactory.GetMP3String(); - } - else if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringFactory.GetAC3String(); - } - else if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringFactory.GetEAC3String(); - } - - return string.Empty; - } - - /// <summary> - /// Gets a formatted string of the output video codec, for use in the CODECS field. - /// </summary> - /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> - /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> - /// <param name="state">StreamState of the current stream.</param> - /// <returns>Formatted video codec string.</returns> - private string GetPlaylistVideoCodecs(StreamState state, string codec, int level) - { - if (level == 0) - { - // This is 0 when there's no requested H.26X level in the device profile - // and the source is not encoded in H.26X - Logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist"); - return string.Empty; - } - - if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) - { - string profile = state.GetRequestedProfiles("h264").FirstOrDefault(); - - return HlsCodecStringFactory.GetH264String(profile, level); - } - else if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - string profile = state.GetRequestedProfiles("h265").FirstOrDefault(); - - return HlsCodecStringFactory.GetH265String(profile, level); - } - - return string.Empty; - } - - /// <summary> - /// Appends a CODECS field containing formatted strings of - /// the active streams output video and audio codecs. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> - /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state) - { - // Video - string videoCodecs = string.Empty; - int? videoCodecLevel = GetOutputVideoCodecLevel(state); - if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue) - { - videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value); - } - - // Audio - string audioCodecs = string.Empty; - if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) - { - audioCodecs = GetPlaylistAudioCodecs(state); - } - - StringBuilder codecs = new StringBuilder(); - - codecs.Append(videoCodecs) - .Append(',') - .Append(audioCodecs); - - if (codecs.Length > 1) - { - builder.Append(",CODECS=\"") - .Append(codecs) - .Append('"'); - } - } - - /// <summary> - /// Appends a FRAME-RATE field containing the framerate of the output stream. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state) - { - double? framerate = null; - if (state.TargetFramerate.HasValue) - { - framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3); - } - else if (state.VideoStream?.RealFrameRate != null) - { - framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3); - } - - if (framerate.HasValue) - { - builder.Append(",FRAME-RATE=") - .Append(framerate.Value); - } - } - - /// <summary> - /// Appends a RESOLUTION field containing the resolution of the output stream. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state) - { - if (state.OutputWidth.HasValue && state.OutputHeight.HasValue) - { - builder.Append(",RESOLUTION=") - .Append(state.OutputWidth.GetValueOrDefault()) - .Append('x') - .Append(state.OutputHeight.GetValueOrDefault()); - } - } - - private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string subtitleGroup) - { - builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") - .Append(bitrate.ToString(CultureInfo.InvariantCulture)) - .Append(",AVERAGE-BANDWIDTH=") - .Append(bitrate.ToString(CultureInfo.InvariantCulture)); - - AppendPlaylistCodecsField(builder, state); - - AppendPlaylistResolutionField(builder, state); - - AppendPlaylistFramerateField(builder, state); - - if (!string.IsNullOrWhiteSpace(subtitleGroup)) - { - builder.Append(",SUBTITLES=\"") - .Append(subtitleGroup) - .Append('"'); - } - - builder.Append(Environment.NewLine); - builder.AppendLine(url); - } - - private int GetBitrateVariation(int bitrate) - { - // By default, vary by just 50k - var variation = 50000; - - if (bitrate >= 10000000) - { - variation = 2000000; - } - else if (bitrate >= 5000000) - { - variation = 1500000; - } - else if (bitrate >= 3000000) - { - variation = 1000000; - } - else if (bitrate >= 2000000) - { - variation = 500000; - } - else if (bitrate >= 1000000) - { - variation = 300000; - } - else if (bitrate >= 600000) - { - variation = 200000; - } - else if (bitrate >= 400000) - { - variation = 100000; - } - - return variation; - } - - private async Task<object> GetVariantPlaylistInternal(StreamRequest request, bool isOutputVideo, string name) - { - var state = await GetState(request, CancellationToken.None).ConfigureAwait(false); - - var segmentLengths = GetSegmentLengths(state); - - var builder = new StringBuilder(); - - builder.AppendLine("#EXTM3U"); - builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); - builder.AppendLine("#EXT-X-VERSION:3"); - builder.AppendLine("#EXT-X-TARGETDURATION:" + Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength).ToString(CultureInfo.InvariantCulture)); - builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); - - var queryStringIndex = Request.RawUrl.IndexOf('?'); - var queryString = queryStringIndex == -1 ? string.Empty : Request.RawUrl.Substring(queryStringIndex); - - // if ((Request.UserAgent ?? string.Empty).IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1) - //{ - // queryString = string.Empty; - //} - - var index = 0; - - foreach (var length in segmentLengths) - { - builder.AppendLine("#EXTINF:" + length.ToString("0.0000", CultureInfo.InvariantCulture) + ", nodesc"); - - builder.AppendLine(string.Format("hls1/{0}/{1}{2}{3}", - - name, - index.ToString(CultureInfo.InvariantCulture), - GetSegmentFileExtension(request), - queryString)); - - index++; - } - - builder.AppendLine("#EXT-X-ENDLIST"); - - var playlistText = builder.ToString(); - - return ResultFactory.GetResult(playlistText, MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>()); - } - - protected override string GetAudioArguments(StreamState state, EncodingOptions encodingOptions) - { - var audioCodec = EncodingHelper.GetAudioEncoder(state); - - if (!state.IsOutputVideo) - { - if (EncodingHelper.IsCopyCodec(audioCodec)) - { - return "-acodec copy"; - } - - var audioTranscodeParams = new List<string>(); - - audioTranscodeParams.Add("-acodec " + audioCodec); - - if (state.OutputAudioBitrate.HasValue) - { - audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture)); - } - - if (state.OutputAudioChannels.HasValue) - { - audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); - } - - if (state.OutputAudioSampleRate.HasValue) - { - audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)); - } - - audioTranscodeParams.Add("-vn"); - return string.Join(" ", audioTranscodeParams.ToArray()); - } - - if (EncodingHelper.IsCopyCodec(audioCodec)) - { - var videoCodec = EncodingHelper.GetVideoEncoder(state, encodingOptions); - - if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) - { - return "-codec:a:0 copy -copypriorss:a:0 0"; - } - - return "-codec:a:0 copy"; - } - - var args = "-codec:a:0 " + audioCodec; - - var channels = state.OutputAudioChannels; - - if (channels.HasValue) - { - args += " -ac " + channels.Value; - } - - var bitrate = state.OutputAudioBitrate; - - if (bitrate.HasValue) - { - args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); - } - - if (state.OutputAudioSampleRate.HasValue) - { - args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); - } - - args += " " + EncodingHelper.GetAudioFilterParam(state, encodingOptions, true); - - return args; - } - - protected override string GetVideoArguments(StreamState state, EncodingOptions encodingOptions) - { - if (!state.IsOutputVideo) - { - return string.Empty; - } - - var codec = EncodingHelper.GetVideoEncoder(state, encodingOptions); - - var args = "-codec:v:0 " + codec; - - // if (state.EnableMpegtsM2TsMode) - // { - // args += " -mpegts_m2ts_mode 1"; - // } - - // See if we can save come cpu cycles by avoiding encoding - if (EncodingHelper.IsCopyCodec(codec)) - { - if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) - { - string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); - if (!string.IsNullOrEmpty(bitStreamArgs)) - { - args += " " + bitStreamArgs; - } - } - - // args += " -flags -global_header"; - } - else - { - var gopArg = string.Empty; - var keyFrameArg = string.Format( - CultureInfo.InvariantCulture, - " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"", - GetStartNumber(state) * state.SegmentLength, - state.SegmentLength); - - var framerate = state.VideoStream?.RealFrameRate; - - if (framerate.HasValue) - { - // This is to make sure keyframe interval is limited to our segment, - // as forcing keyframes is not enough. - // Example: we encoded half of desired length, then codec detected - // scene cut and inserted a keyframe; next forced keyframe would - // be created outside of segment, which breaks seeking - // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe - gopArg = string.Format( - CultureInfo.InvariantCulture, - " -g {0} -keyint_min {0} -sc_threshold 0", - Math.Ceiling(state.SegmentLength * framerate.Value) - ); - } - - args += " " + EncodingHelper.GetVideoQualityParam(state, codec, encodingOptions, GetDefaultEncoderPreset()); - - // Unable to force key frames using these hw encoders, set key frames by GOP - if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)) - { - args += " " + gopArg; - } - else - { - args += " " + keyFrameArg + gopArg; - } - - // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; - - var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - - // This is for graphical subs - if (hasGraphicalSubs) - { - args += EncodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec); - } - // Add resolution params, if specified - else - { - args += EncodingHelper.GetOutputSizeParam(state, encodingOptions, codec); - } - - // -start_at_zero is necessary to use with -ss when seeking, - // otherwise the target position cannot be determined. - if (!(state.SubtitleStream != null && state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)) - { - args += " -start_at_zero"; - } - - // args += " -flags -global_header"; - } - - if (!string.IsNullOrEmpty(state.OutputVideoSync)) - { - args += " -vsync " + state.OutputVideoSync; - } - - args += EncodingHelper.GetOutputFFlags(state); - - return args; - } - - protected override string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding) - { - var videoCodec = EncodingHelper.GetVideoEncoder(state, encodingOptions); - - var threads = EncodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); - - if (state.BaseRequest.BreakOnNonKeyFrames) - { - // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe - // breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable - // to produce a missing part of video stream before first keyframe is encountered, which may lead to - // awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js - Logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request"); - state.BaseRequest.BreakOnNonKeyFrames = false; - } - - var inputModifier = EncodingHelper.GetInputModifier(state, encodingOptions); - - // If isEncoding is true we're actually starting ffmpeg - var startNumber = GetStartNumber(state); - var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0"; - - var mapArgs = state.IsOutputVideo ? EncodingHelper.GetMapArgs(state) : string.Empty; - - var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request); - - var segmentFormat = GetSegmentFileExtension(state.Request).TrimStart('.'); - if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) - { - segmentFormat = "mpegts"; - } - - return string.Format( - "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -f hls -max_delay 5000000 -hls_time {6} -individual_header_trailer 0 -hls_segment_type {7} -start_number {8} -hls_segment_filename \"{9}\" -hls_playlist_type vod -hls_list_size 0 -y \"{10}\"", - inputModifier, - EncodingHelper.GetInputArgument(state, encodingOptions), - threads, - mapArgs, - GetVideoArguments(state, encodingOptions), - GetAudioArguments(state, encodingOptions), - state.SegmentLength.ToString(CultureInfo.InvariantCulture), - segmentFormat, - startNumberParam, - outputTsArg, - outputPath - ).Trim(); - } - } -} diff --git a/MediaBrowser.Api/Playback/Hls/HlsSegmentService.cs b/MediaBrowser.Api/Playback/Hls/HlsSegmentService.cs deleted file mode 100644 index 8a3d00283..000000000 --- a/MediaBrowser.Api/Playback/Hls/HlsSegmentService.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Playback.Hls -{ - /// <summary> - /// Class GetHlsAudioSegment. - /// </summary> - // Can't require authentication just yet due to seeing some requests come from Chrome without full query string - //[Authenticated] - [Route("/Audio/{Id}/hls/{SegmentId}/stream.mp3", "GET")] - [Route("/Audio/{Id}/hls/{SegmentId}/stream.aac", "GET")] - public class GetHlsAudioSegmentLegacy - { - // TODO: Deprecate with new iOS app - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - public string Id { get; set; } - - /// <summary> - /// Gets or sets the segment id. - /// </summary> - /// <value>The segment id.</value> - public string SegmentId { get; set; } - } - - /// <summary> - /// Class GetHlsVideoSegment. - /// </summary> - [Route("/Videos/{Id}/hls/{PlaylistId}/stream.m3u8", "GET")] - [Authenticated] - public class GetHlsPlaylistLegacy - { - // TODO: Deprecate with new iOS app - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - public string Id { get; set; } - - public string PlaylistId { get; set; } - } - - [Route("/Videos/ActiveEncodings", "DELETE")] - [Authenticated] - public class StopEncodingProcess - { - [ApiMember(Name = "DeviceId", Description = "The device id of the client requesting. Used to stop encoding processes when needed.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string DeviceId { get; set; } - - [ApiMember(Name = "PlaySessionId", Description = "The play session id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string PlaySessionId { get; set; } - } - - /// <summary> - /// Class GetHlsVideoSegment. - /// </summary> - // Can't require authentication just yet due to seeing some requests come from Chrome without full query string - //[Authenticated] - [Route("/Videos/{Id}/hls/{PlaylistId}/{SegmentId}.{SegmentContainer}", "GET")] - public class GetHlsVideoSegmentLegacy : VideoStreamRequest - { - public string PlaylistId { get; set; } - - /// <summary> - /// Gets or sets the segment id. - /// </summary> - /// <value>The segment id.</value> - public string SegmentId { get; set; } - } - - public class HlsSegmentService : BaseApiService - { - private readonly IFileSystem _fileSystem; - - public HlsSegmentService( - ILogger<HlsSegmentService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IFileSystem fileSystem) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _fileSystem = fileSystem; - } - - public Task<object> Get(GetHlsPlaylistLegacy request) - { - var file = request.PlaylistId + Path.GetExtension(Request.PathInfo); - file = Path.Combine(ServerConfigurationManager.GetTranscodePath(), file); - - return GetFileResult(file, file); - } - - public Task Delete(StopEncodingProcess request) - { - return ApiEntryPoint.Instance.KillTranscodingJobs(request.DeviceId, request.PlaySessionId, path => true); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public Task<object> Get(GetHlsVideoSegmentLegacy request) - { - var file = request.SegmentId + Path.GetExtension(Request.PathInfo); - var transcodeFolderPath = ServerConfigurationManager.GetTranscodePath(); - - file = Path.Combine(transcodeFolderPath, file); - - var normalizedPlaylistId = request.PlaylistId; - - var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath) - .FirstOrDefault(i => string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase) && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1); - - return GetFileResult(file, playlistPath); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public Task<object> Get(GetHlsAudioSegmentLegacy request) - { - // TODO: Deprecate with new iOS app - var file = request.SegmentId + Path.GetExtension(Request.PathInfo); - file = Path.Combine(ServerConfigurationManager.GetTranscodePath(), file); - - return ResultFactory.GetStaticFileResult(Request, file, FileShare.ReadWrite); - } - - private Task<object> GetFileResult(string path, string playlistPath) - { - var transcodingJob = ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); - - return ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions - { - Path = path, - FileShare = FileShare.ReadWrite, - OnComplete = () => - { - if (transcodingJob != null) - { - ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob); - } - } - }); - } - } -} diff --git a/MediaBrowser.Api/Playback/Hls/VideoHlsService.cs b/MediaBrowser.Api/Playback/Hls/VideoHlsService.cs deleted file mode 100644 index 9562f9953..000000000 --- a/MediaBrowser.Api/Playback/Hls/VideoHlsService.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Globalization; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Playback.Hls -{ - [Route("/Videos/{Id}/live.m3u8", "GET")] - public class GetLiveHlsStream : VideoStreamRequest - { - } - - /// <summary> - /// Class VideoHlsService. - /// </summary> - [Authenticated] - public class VideoHlsService : BaseHlsService - { - public VideoHlsService( - ILogger<VideoHlsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IIsoManager isoManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - IMediaSourceManager mediaSourceManager, - IJsonSerializer jsonSerializer, - IAuthorizationContext authorizationContext, - EncodingHelper encodingHelper) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - isoManager, - mediaEncoder, - fileSystem, - dlnaManager, - deviceManager, - mediaSourceManager, - jsonSerializer, - authorizationContext, - encodingHelper) - { - } - - public Task<object> Get(GetLiveHlsStream request) - { - return ProcessRequestAsync(request, true); - } - - /// <summary> - /// Gets the audio arguments. - /// </summary> - protected override string GetAudioArguments(StreamState state, EncodingOptions encodingOptions) - { - var codec = EncodingHelper.GetAudioEncoder(state); - - if (EncodingHelper.IsCopyCodec(codec)) - { - return "-codec:a:0 copy"; - } - - var args = "-codec:a:0 " + codec; - - var channels = state.OutputAudioChannels; - - if (channels.HasValue) - { - args += " -ac " + channels.Value; - } - - var bitrate = state.OutputAudioBitrate; - - if (bitrate.HasValue) - { - args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); - } - - if (state.OutputAudioSampleRate.HasValue) - { - args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); - } - - args += " " + EncodingHelper.GetAudioFilterParam(state, encodingOptions, true); - - return args; - } - - /// <summary> - /// Gets the video arguments. - /// </summary> - protected override string GetVideoArguments(StreamState state, EncodingOptions encodingOptions) - { - if (!state.IsOutputVideo) - { - return string.Empty; - } - - var codec = EncodingHelper.GetVideoEncoder(state, encodingOptions); - - var args = "-codec:v:0 " + codec; - - // if (state.EnableMpegtsM2TsMode) - // { - // args += " -mpegts_m2ts_mode 1"; - // } - - // See if we can save come cpu cycles by avoiding encoding - if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase)) - { - // if h264_mp4toannexb is ever added, do not use it for live tv - if (state.VideoStream != null && - !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) - { - string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); - if (!string.IsNullOrEmpty(bitStreamArgs)) - { - args += " " + bitStreamArgs; - } - } - } - else - { - var keyFrameArg = string.Format(" -force_key_frames \"expr:gte(t,n_forced*{0})\"", - state.SegmentLength.ToString(CultureInfo.InvariantCulture)); - - var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - - args += " " + EncodingHelper.GetVideoQualityParam(state, codec, encodingOptions, GetDefaultEncoderPreset()) + keyFrameArg; - - // Add resolution params, if specified - if (!hasGraphicalSubs) - { - args += EncodingHelper.GetOutputSizeParam(state, encodingOptions, codec); - } - - // This is for internal graphical subs - if (hasGraphicalSubs) - { - args += EncodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec); - } - } - - args += " -flags -global_header"; - - if (!string.IsNullOrEmpty(state.OutputVideoSync)) - { - args += " -vsync " + state.OutputVideoSync; - } - - args += EncodingHelper.GetOutputFFlags(state); - - return args; - } - } -} diff --git a/MediaBrowser.Api/Playback/Progressive/AudioService.cs b/MediaBrowser.Api/Playback/Progressive/AudioService.cs deleted file mode 100644 index d51787df2..000000000 --- a/MediaBrowser.Api/Playback/Progressive/AudioService.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Playback.Progressive -{ - /// <summary> - /// Class GetAudioStream. - /// </summary> - [Route("/Audio/{Id}/stream.{Container}", "GET", Summary = "Gets an audio stream")] - [Route("/Audio/{Id}/stream", "GET", Summary = "Gets an audio stream")] - [Route("/Audio/{Id}/stream.{Container}", "HEAD", Summary = "Gets an audio stream")] - [Route("/Audio/{Id}/stream", "HEAD", Summary = "Gets an audio stream")] - public class GetAudioStream : StreamRequest - { - } - - /// <summary> - /// Class AudioService. - /// </summary> - // TODO: In order to autheneticate this in the future, Dlna playback will require updating - //[Authenticated] - public class AudioService : BaseProgressiveStreamingService - { - public AudioService( - ILogger<AudioService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IHttpClient httpClient, - IUserManager userManager, - ILibraryManager libraryManager, - IIsoManager isoManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - IMediaSourceManager mediaSourceManager, - IJsonSerializer jsonSerializer, - IAuthorizationContext authorizationContext, - EncodingHelper encodingHelper) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - httpClient, - userManager, - libraryManager, - isoManager, - mediaEncoder, - fileSystem, - dlnaManager, - deviceManager, - mediaSourceManager, - jsonSerializer, - authorizationContext, - encodingHelper) - { - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public Task<object> Get(GetAudioStream request) - { - return ProcessRequest(request, false); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public Task<object> Head(GetAudioStream request) - { - return ProcessRequest(request, true); - } - - protected override string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding) - { - return EncodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); - } - } -} diff --git a/MediaBrowser.Api/Playback/Progressive/BaseProgressiveStreamingService.cs b/MediaBrowser.Api/Playback/Progressive/BaseProgressiveStreamingService.cs deleted file mode 100644 index 2ebf0e420..000000000 --- a/MediaBrowser.Api/Playback/Progressive/BaseProgressiveStreamingService.cs +++ /dev/null @@ -1,442 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace MediaBrowser.Api.Playback.Progressive -{ - /// <summary> - /// Class BaseProgressiveStreamingService. - /// </summary> - public abstract class BaseProgressiveStreamingService : BaseStreamingService - { - protected IHttpClient HttpClient { get; private set; } - - public BaseProgressiveStreamingService( - ILogger<BaseProgressiveStreamingService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IHttpClient httpClient, - IUserManager userManager, - ILibraryManager libraryManager, - IIsoManager isoManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - IMediaSourceManager mediaSourceManager, - IJsonSerializer jsonSerializer, - IAuthorizationContext authorizationContext, - EncodingHelper encodingHelper) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - isoManager, - mediaEncoder, - fileSystem, - dlnaManager, - deviceManager, - mediaSourceManager, - jsonSerializer, - authorizationContext, - encodingHelper) - { - HttpClient = httpClient; - } - - /// <summary> - /// Gets the output file extension. - /// </summary> - /// <param name="state">The state.</param> - /// <returns>System.String.</returns> - protected override string GetOutputFileExtension(StreamState state) - { - var ext = base.GetOutputFileExtension(state); - - if (!string.IsNullOrEmpty(ext)) - { - return ext; - } - - var isVideoRequest = state.VideoRequest != null; - - // Try to infer based on the desired video codec - if (isVideoRequest) - { - var videoCodec = state.VideoRequest.VideoCodec; - - if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) || - string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase)) - { - return ".ts"; - } - - if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase)) - { - return ".ogv"; - } - - if (string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) - { - return ".webm"; - } - - if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) - { - return ".asf"; - } - } - - // Try to infer based on the desired audio codec - if (!isVideoRequest) - { - var audioCodec = state.Request.AudioCodec; - - if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".aac"; - } - - if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".mp3"; - } - - if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".ogg"; - } - - if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".wma"; - } - } - - return null; - } - - /// <summary> - /// Gets the type of the transcoding job. - /// </summary> - /// <value>The type of the transcoding job.</value> - protected override TranscodingJobType TranscodingJobType => TranscodingJobType.Progressive; - - /// <summary> - /// Processes the request. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param> - /// <returns>Task.</returns> - protected async Task<object> ProcessRequest(StreamRequest request, bool isHeadRequest) - { - var cancellationTokenSource = new CancellationTokenSource(); - - var state = await GetState(request, cancellationTokenSource.Token).ConfigureAwait(false); - - var responseHeaders = new Dictionary<string, string>(); - - if (request.Static && state.DirectStreamProvider != null) - { - AddDlnaHeaders(state, responseHeaders, true); - - using (state) - { - var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - // TODO: Don't hardcode this - outputHeaders[HeaderNames.ContentType] = Model.Net.MimeTypes.GetMimeType("file.ts"); - - return new ProgressiveFileCopier(state.DirectStreamProvider, outputHeaders, null, Logger, CancellationToken.None) - { - AllowEndOfFile = false - }; - } - } - - // Static remote stream - if (request.Static && state.InputProtocol == MediaProtocol.Http) - { - AddDlnaHeaders(state, responseHeaders, true); - - using (state) - { - return await GetStaticRemoteStreamResult(state, responseHeaders, isHeadRequest, cancellationTokenSource).ConfigureAwait(false); - } - } - - if (request.Static && state.InputProtocol != MediaProtocol.File) - { - throw new ArgumentException(string.Format("Input protocol {0} cannot be streamed statically.", state.InputProtocol)); - } - - var outputPath = state.OutputFilePath; - var outputPathExists = File.Exists(outputPath); - - var transcodingJob = ApiEntryPoint.Instance.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); - var isTranscodeCached = outputPathExists && transcodingJob != null; - - AddDlnaHeaders(state, responseHeaders, request.Static || isTranscodeCached); - - // Static stream - if (request.Static) - { - var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); - - using (state) - { - if (state.MediaSource.IsInfiniteStream) - { - var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) - { - [HeaderNames.ContentType] = contentType - }; - - - return new ProgressiveFileCopier(FileSystem, state.MediaPath, outputHeaders, null, Logger, CancellationToken.None) - { - AllowEndOfFile = false - }; - } - - TimeSpan? cacheDuration = null; - - if (!string.IsNullOrEmpty(request.Tag)) - { - cacheDuration = TimeSpan.FromDays(365); - } - - return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions - { - ResponseHeaders = responseHeaders, - ContentType = contentType, - IsHeadRequest = isHeadRequest, - Path = state.MediaPath, - CacheDuration = cacheDuration - - }).ConfigureAwait(false); - } - } - - //// Not static but transcode cache file exists - // if (isTranscodeCached && state.VideoRequest == null) - //{ - // var contentType = state.GetMimeType(outputPath); - - // try - // { - // if (transcodingJob != null) - // { - // ApiEntryPoint.Instance.OnTranscodeBeginRequest(transcodingJob); - // } - - // return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions - // { - // ResponseHeaders = responseHeaders, - // ContentType = contentType, - // IsHeadRequest = isHeadRequest, - // Path = outputPath, - // FileShare = FileShare.ReadWrite, - // OnComplete = () => - // { - // if (transcodingJob != null) - // { - // ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob); - // } - // } - - // }).ConfigureAwait(false); - // } - // finally - // { - // state.Dispose(); - // } - //} - - // Need to start ffmpeg - try - { - return await GetStreamResult(request, state, responseHeaders, isHeadRequest, cancellationTokenSource).ConfigureAwait(false); - } - catch - { - state.Dispose(); - - throw; - } - } - - /// <summary> - /// Gets the static remote stream result. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="responseHeaders">The response headers.</param> - /// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param> - /// <param name="cancellationTokenSource">The cancellation token source.</param> - /// <returns>Task{System.Object}.</returns> - private async Task<object> GetStaticRemoteStreamResult( - StreamState state, - Dictionary<string, string> responseHeaders, - bool isHeadRequest, - CancellationTokenSource cancellationTokenSource) - { - var options = new HttpRequestOptions - { - Url = state.MediaPath, - BufferContent = false, - CancellationToken = cancellationTokenSource.Token - }; - - if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) - { - options.UserAgent = useragent; - } - - var response = await HttpClient.GetResponse(options).ConfigureAwait(false); - - responseHeaders[HeaderNames.AcceptRanges] = "none"; - - // Seeing cases of -1 here - if (response.ContentLength.HasValue && response.ContentLength.Value >= 0) - { - responseHeaders[HeaderNames.ContentLength] = response.ContentLength.Value.ToString(CultureInfo.InvariantCulture); - } - - if (isHeadRequest) - { - using (response) - { - return ResultFactory.GetResult(null, Array.Empty<byte>(), response.ContentType, responseHeaders); - } - } - - var result = new StaticRemoteStreamWriter(response); - - result.Headers[HeaderNames.ContentType] = response.ContentType; - - // Add the response headers to the result object - foreach (var header in responseHeaders) - { - result.Headers[header.Key] = header.Value; - } - - return result; - } - - /// <summary> - /// Gets the stream result. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="responseHeaders">The response headers.</param> - /// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param> - /// <param name="cancellationTokenSource">The cancellation token source.</param> - /// <returns>Task{System.Object}.</returns> - private async Task<object> GetStreamResult(StreamRequest request, StreamState state, IDictionary<string, string> responseHeaders, bool isHeadRequest, CancellationTokenSource cancellationTokenSource) - { - // Use the command line args with a dummy playlist path - var outputPath = state.OutputFilePath; - - responseHeaders[HeaderNames.AcceptRanges] = "none"; - - var contentType = state.GetMimeType(outputPath); - - // TODO: The isHeadRequest is only here because ServiceStack will add Content-Length=0 to the response - var contentLength = state.EstimateContentLength || isHeadRequest ? GetEstimatedContentLength(state) : null; - - if (contentLength.HasValue) - { - responseHeaders[HeaderNames.ContentLength] = contentLength.Value.ToString(CultureInfo.InvariantCulture); - } - - // Headers only - if (isHeadRequest) - { - var streamResult = ResultFactory.GetResult(null, Array.Empty<byte>(), contentType, responseHeaders); - - if (streamResult is IHasHeaders hasHeaders) - { - if (contentLength.HasValue) - { - hasHeaders.Headers[HeaderNames.ContentLength] = contentLength.Value.ToString(CultureInfo.InvariantCulture); - } - else - { - hasHeaders.Headers.Remove(HeaderNames.ContentLength); - } - } - - return streamResult; - } - - var transcodingLock = ApiEntryPoint.Instance.GetTranscodingLock(outputPath); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); - try - { - TranscodingJob job; - - if (!File.Exists(outputPath)) - { - job = await StartFfMpeg(state, outputPath, cancellationTokenSource).ConfigureAwait(false); - } - else - { - job = ApiEntryPoint.Instance.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); - state.Dispose(); - } - - var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) - { - [HeaderNames.ContentType] = contentType - }; - - - // Add the response headers to the result object - foreach (var item in responseHeaders) - { - outputHeaders[item.Key] = item.Value; - } - - return new ProgressiveFileCopier(FileSystem, outputPath, outputHeaders, job, Logger, CancellationToken.None); - } - finally - { - transcodingLock.Release(); - } - } - - /// <summary> - /// Gets the length of the estimated content. - /// </summary> - /// <param name="state">The state.</param> - /// <returns>System.Nullable{System.Int64}.</returns> - private long? GetEstimatedContentLength(StreamState state) - { - var totalBitrate = state.TotalOutputBitrate ?? 0; - - if (totalBitrate > 0 && state.RunTimeTicks.HasValue) - { - return Convert.ToInt64(totalBitrate * TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalSeconds / 8); - } - - return null; - } - } -} diff --git a/MediaBrowser.Api/Playback/Progressive/ProgressiveStreamWriter.cs b/MediaBrowser.Api/Playback/Progressive/ProgressiveStreamWriter.cs deleted file mode 100644 index b70fff128..000000000 --- a/MediaBrowser.Api/Playback/Progressive/ProgressiveStreamWriter.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.System; -using Microsoft.Extensions.Logging; -using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; - -namespace MediaBrowser.Api.Playback.Progressive -{ - public class ProgressiveFileCopier : IAsyncStreamWriter, IHasHeaders - { - private readonly IFileSystem _fileSystem; - private readonly TranscodingJob _job; - private readonly ILogger _logger; - private readonly string _path; - private readonly CancellationToken _cancellationToken; - private readonly Dictionary<string, string> _outputHeaders; - - private long _bytesWritten = 0; - public long StartPosition { get; set; } - - public bool AllowEndOfFile = true; - - private readonly IDirectStreamProvider _directStreamProvider; - - public ProgressiveFileCopier(IFileSystem fileSystem, string path, Dictionary<string, string> outputHeaders, TranscodingJob job, ILogger logger, CancellationToken cancellationToken) - { - _fileSystem = fileSystem; - _path = path; - _outputHeaders = outputHeaders; - _job = job; - _logger = logger; - _cancellationToken = cancellationToken; - } - - public ProgressiveFileCopier(IDirectStreamProvider directStreamProvider, Dictionary<string, string> outputHeaders, TranscodingJob job, ILogger logger, CancellationToken cancellationToken) - { - _directStreamProvider = directStreamProvider; - _outputHeaders = outputHeaders; - _job = job; - _logger = logger; - _cancellationToken = cancellationToken; - } - - public IDictionary<string, string> Headers => _outputHeaders; - - private Stream GetInputStream(bool allowAsyncFileRead) - { - var fileOptions = FileOptions.SequentialScan; - - if (allowAsyncFileRead) - { - fileOptions |= FileOptions.Asynchronous; - } - - return new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); - } - - public async Task WriteToAsync(Stream outputStream, CancellationToken cancellationToken) - { - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken).Token; - - try - { - if (_directStreamProvider != null) - { - await _directStreamProvider.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); - return; - } - - var eofCount = 0; - - // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 - var allowAsyncFileRead = OperatingSystem.Id != OperatingSystemId.Windows; - - using (var inputStream = GetInputStream(allowAsyncFileRead)) - { - if (StartPosition > 0) - { - inputStream.Position = StartPosition; - } - - while (eofCount < 20 || !AllowEndOfFile) - { - int bytesRead; - if (allowAsyncFileRead) - { - bytesRead = await CopyToInternalAsync(inputStream, outputStream, cancellationToken).ConfigureAwait(false); - } - else - { - bytesRead = await CopyToInternalAsyncWithSyncRead(inputStream, outputStream, cancellationToken).ConfigureAwait(false); - } - - // var position = fs.Position; - // _logger.LogDebug("Streamed {0} bytes to position {1} from file {2}", bytesRead, position, path); - - if (bytesRead == 0) - { - if (_job == null || _job.HasExited) - { - eofCount++; - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - else - { - eofCount = 0; - } - } - } - } - finally - { - if (_job != null) - { - ApiEntryPoint.Instance.OnTranscodeEndRequest(_job); - } - } - } - - private async Task<int> CopyToInternalAsyncWithSyncRead(Stream source, Stream destination, CancellationToken cancellationToken) - { - var array = new byte[IODefaults.CopyToBufferSize]; - int bytesRead; - int totalBytesRead = 0; - - while ((bytesRead = source.Read(array, 0, array.Length)) != 0) - { - var bytesToWrite = bytesRead; - - if (bytesToWrite > 0) - { - await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); - - _bytesWritten += bytesRead; - totalBytesRead += bytesRead; - - if (_job != null) - { - _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); - } - } - } - - return totalBytesRead; - } - - private async Task<int> CopyToInternalAsync(Stream source, Stream destination, CancellationToken cancellationToken) - { - var array = new byte[IODefaults.CopyToBufferSize]; - int bytesRead; - int totalBytesRead = 0; - - while ((bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false)) != 0) - { - var bytesToWrite = bytesRead; - - if (bytesToWrite > 0) - { - await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); - - _bytesWritten += bytesRead; - totalBytesRead += bytesRead; - - if (_job != null) - { - _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); - } - } - } - - return totalBytesRead; - } - } -} diff --git a/MediaBrowser.Api/Playback/Progressive/VideoService.cs b/MediaBrowser.Api/Playback/Progressive/VideoService.cs deleted file mode 100644 index c3f6b905c..000000000 --- a/MediaBrowser.Api/Playback/Progressive/VideoService.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Playback.Progressive -{ - /// <summary> - /// Class GetVideoStream. - /// </summary> - [Route("/Videos/{Id}/stream.mpegts", "GET")] - [Route("/Videos/{Id}/stream.ts", "GET")] - [Route("/Videos/{Id}/stream.webm", "GET")] - [Route("/Videos/{Id}/stream.asf", "GET")] - [Route("/Videos/{Id}/stream.wmv", "GET")] - [Route("/Videos/{Id}/stream.ogv", "GET")] - [Route("/Videos/{Id}/stream.mp4", "GET")] - [Route("/Videos/{Id}/stream.m4v", "GET")] - [Route("/Videos/{Id}/stream.mkv", "GET")] - [Route("/Videos/{Id}/stream.mpeg", "GET")] - [Route("/Videos/{Id}/stream.mpg", "GET")] - [Route("/Videos/{Id}/stream.avi", "GET")] - [Route("/Videos/{Id}/stream.m2ts", "GET")] - [Route("/Videos/{Id}/stream.3gp", "GET")] - [Route("/Videos/{Id}/stream.wmv", "GET")] - [Route("/Videos/{Id}/stream.wtv", "GET")] - [Route("/Videos/{Id}/stream.mov", "GET")] - [Route("/Videos/{Id}/stream.iso", "GET")] - [Route("/Videos/{Id}/stream.flv", "GET")] - [Route("/Videos/{Id}/stream.rm", "GET")] - [Route("/Videos/{Id}/stream", "GET")] - [Route("/Videos/{Id}/stream.ts", "HEAD")] - [Route("/Videos/{Id}/stream.webm", "HEAD")] - [Route("/Videos/{Id}/stream.asf", "HEAD")] - [Route("/Videos/{Id}/stream.wmv", "HEAD")] - [Route("/Videos/{Id}/stream.ogv", "HEAD")] - [Route("/Videos/{Id}/stream.mp4", "HEAD")] - [Route("/Videos/{Id}/stream.m4v", "HEAD")] - [Route("/Videos/{Id}/stream.mkv", "HEAD")] - [Route("/Videos/{Id}/stream.mpeg", "HEAD")] - [Route("/Videos/{Id}/stream.mpg", "HEAD")] - [Route("/Videos/{Id}/stream.avi", "HEAD")] - [Route("/Videos/{Id}/stream.3gp", "HEAD")] - [Route("/Videos/{Id}/stream.wmv", "HEAD")] - [Route("/Videos/{Id}/stream.wtv", "HEAD")] - [Route("/Videos/{Id}/stream.m2ts", "HEAD")] - [Route("/Videos/{Id}/stream.mov", "HEAD")] - [Route("/Videos/{Id}/stream.iso", "HEAD")] - [Route("/Videos/{Id}/stream.flv", "HEAD")] - [Route("/Videos/{Id}/stream", "HEAD")] - public class GetVideoStream : VideoStreamRequest - { - } - - /// <summary> - /// Class VideoService. - /// </summary> - // TODO: In order to autheneticate this in the future, Dlna playback will require updating - //[Authenticated] - public class VideoService : BaseProgressiveStreamingService - { - public VideoService( - ILogger<VideoService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IHttpClient httpClient, - IUserManager userManager, - ILibraryManager libraryManager, - IIsoManager isoManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - IMediaSourceManager mediaSourceManager, - IJsonSerializer jsonSerializer, - IAuthorizationContext authorizationContext, - EncodingHelper encodingHelper) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - httpClient, - userManager, - libraryManager, - isoManager, - mediaEncoder, - fileSystem, - dlnaManager, - deviceManager, - mediaSourceManager, - jsonSerializer, - authorizationContext, - encodingHelper) - { - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public Task<object> Get(GetVideoStream request) - { - return ProcessRequest(request, false); - } - - /// <summary> - /// Heads the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public Task<object> Head(GetVideoStream request) - { - return ProcessRequest(request, true); - } - - protected override string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding) - { - return EncodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, GetDefaultEncoderPreset()); - } - } -} diff --git a/MediaBrowser.Api/Playback/StaticRemoteStreamWriter.cs b/MediaBrowser.Api/Playback/StaticRemoteStreamWriter.cs deleted file mode 100644 index 7e2e337ad..000000000 --- a/MediaBrowser.Api/Playback/StaticRemoteStreamWriter.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Model.Services; - -namespace MediaBrowser.Api.Playback -{ - /// <summary> - /// Class StaticRemoteStreamWriter. - /// </summary> - public class StaticRemoteStreamWriter : IAsyncStreamWriter, IHasHeaders - { - /// <summary> - /// The _input stream. - /// </summary> - private readonly HttpResponseInfo _response; - - /// <summary> - /// The _options. - /// </summary> - private readonly IDictionary<string, string> _options = new Dictionary<string, string>(); - - public StaticRemoteStreamWriter(HttpResponseInfo response) - { - _response = response; - } - - /// <summary> - /// Gets the options. - /// </summary> - /// <value>The options.</value> - public IDictionary<string, string> Headers => _options; - - public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken) - { - using (_response) - { - await _response.Content.CopyToAsync(responseStream, 81920, cancellationToken).ConfigureAwait(false); - } - } - } -} diff --git a/MediaBrowser.Api/Playback/StreamRequest.cs b/MediaBrowser.Api/Playback/StreamRequest.cs deleted file mode 100644 index 67c334e48..000000000 --- a/MediaBrowser.Api/Playback/StreamRequest.cs +++ /dev/null @@ -1,37 +0,0 @@ -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Services; - -namespace MediaBrowser.Api.Playback -{ - /// <summary> - /// Class StreamRequest. - /// </summary> - public class StreamRequest : BaseEncodingJobOptions - { - [ApiMember(Name = "DeviceProfileId", Description = "Optional. The dlna device profile id to utilize.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string DeviceProfileId { get; set; } - - public string Params { get; set; } - - public string PlaySessionId { get; set; } - - public string Tag { get; set; } - - public string SegmentContainer { get; set; } - - public int? SegmentLength { get; set; } - - public int? MinSegments { get; set; } - } - - public class VideoStreamRequest : StreamRequest - { - /// <summary> - /// Gets a value indicating whether this instance has fixed resolution. - /// </summary> - /// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value> - public bool HasFixedResolution => Width.HasValue || Height.HasValue; - - public bool EnableSubtitlesInManifest { get; set; } - } -} diff --git a/MediaBrowser.Api/Playback/StreamState.cs b/MediaBrowser.Api/Playback/StreamState.cs deleted file mode 100644 index c244b0033..000000000 --- a/MediaBrowser.Api/Playback/StreamState.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Dlna; - -namespace MediaBrowser.Api.Playback -{ - public class StreamState : EncodingJobInfo, IDisposable - { - private readonly IMediaSourceManager _mediaSourceManager; - private bool _disposed = false; - - public string RequestedUrl { get; set; } - - public StreamRequest Request - { - get => (StreamRequest)BaseRequest; - set - { - BaseRequest = value; - - IsVideoRequest = VideoRequest != null; - } - } - - public TranscodingThrottler TranscodingThrottler { get; set; } - - public VideoStreamRequest VideoRequest => Request as VideoStreamRequest; - - public IDirectStreamProvider DirectStreamProvider { get; set; } - - public string WaitForPath { get; set; } - - public bool IsOutputVideo => Request is VideoStreamRequest; - - public int SegmentLength - { - get - { - if (Request.SegmentLength.HasValue) - { - return Request.SegmentLength.Value; - } - - if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) - { - var userAgent = UserAgent ?? string.Empty; - - if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1 || - userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1 || - userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 || - userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 || - userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) - { - if (IsSegmentedLiveStream) - { - return 6; - } - - return 6; - } - - if (IsSegmentedLiveStream) - { - return 3; - } - - return 6; - } - - return 3; - } - } - - public int MinSegments - { - get - { - if (Request.MinSegments.HasValue) - { - return Request.MinSegments.Value; - } - - return SegmentLength >= 10 ? 2 : 3; - } - } - - public string UserAgent { get; set; } - - public bool EstimateContentLength { get; set; } - - public TranscodeSeekInfo TranscodeSeekInfo { get; set; } - - public bool EnableDlnaHeaders { get; set; } - - public DeviceProfile DeviceProfile { get; set; } - - public TranscodingJob TranscodingJob { get; set; } - - public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType) - : base(transcodingType) - { - _mediaSourceManager = mediaSourceManager; - } - - public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) - { - ApiEntryPoint.Instance.ReportTranscodingProgress(TranscodingJob, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - // REVIEW: Is this the right place for this? - if (MediaSource.RequiresClosing - && string.IsNullOrWhiteSpace(Request.LiveStreamId) - && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) - { - _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); - } - - TranscodingThrottler?.Dispose(); - } - - TranscodingThrottler = null; - TranscodingJob = null; - - _disposed = true; - } - } -} diff --git a/MediaBrowser.Api/Playback/UniversalAudioService.cs b/MediaBrowser.Api/Playback/UniversalAudioService.cs deleted file mode 100644 index d5d78cf37..000000000 --- a/MediaBrowser.Api/Playback/UniversalAudioService.cs +++ /dev/null @@ -1,401 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Api.Playback.Hls; -using MediaBrowser.Api.Playback.Progressive; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Playback -{ - public class BaseUniversalRequest - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - - [ApiMember(Name = "MediaSourceId", Description = "The media version id, if playing an alternate version", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string MediaSourceId { get; set; } - - [ApiMember(Name = "DeviceId", Description = "The device id of the client requesting. Used to stop encoding processes when needed.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string DeviceId { get; set; } - - public Guid UserId { get; set; } - - public string AudioCodec { get; set; } - - public string Container { get; set; } - - public int? MaxAudioChannels { get; set; } - - public int? TranscodingAudioChannels { get; set; } - - public long? MaxStreamingBitrate { get; set; } - - [ApiMember(Name = "StartTimeTicks", Description = "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public long? StartTimeTicks { get; set; } - - public string TranscodingContainer { get; set; } - - public string TranscodingProtocol { get; set; } - - public int? MaxAudioSampleRate { get; set; } - - public int? MaxAudioBitDepth { get; set; } - - public bool EnableRedirection { get; set; } - - public bool EnableRemoteMedia { get; set; } - - public bool BreakOnNonKeyFrames { get; set; } - - public BaseUniversalRequest() - { - EnableRedirection = true; - } - } - - [Route("/Audio/{Id}/universal.{Container}", "GET", Summary = "Gets an audio stream")] - [Route("/Audio/{Id}/universal", "GET", Summary = "Gets an audio stream")] - [Route("/Audio/{Id}/universal.{Container}", "HEAD", Summary = "Gets an audio stream")] - [Route("/Audio/{Id}/universal", "HEAD", Summary = "Gets an audio stream")] - public class GetUniversalAudioStream : BaseUniversalRequest - { - } - - [Authenticated] - public class UniversalAudioService : BaseApiService - { - private readonly EncodingHelper _encodingHelper; - private readonly ILoggerFactory _loggerFactory; - - public UniversalAudioService( - ILogger<UniversalAudioService> logger, - ILoggerFactory loggerFactory, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IHttpClient httpClient, - IUserManager userManager, - ILibraryManager libraryManager, - IIsoManager isoManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - IMediaSourceManager mediaSourceManager, - IJsonSerializer jsonSerializer, - IAuthorizationContext authorizationContext, - INetworkManager networkManager, - EncodingHelper encodingHelper) - : base(logger, serverConfigurationManager, httpResultFactory) - { - HttpClient = httpClient; - UserManager = userManager; - LibraryManager = libraryManager; - IsoManager = isoManager; - MediaEncoder = mediaEncoder; - FileSystem = fileSystem; - DlnaManager = dlnaManager; - DeviceManager = deviceManager; - MediaSourceManager = mediaSourceManager; - JsonSerializer = jsonSerializer; - AuthorizationContext = authorizationContext; - NetworkManager = networkManager; - _encodingHelper = encodingHelper; - _loggerFactory = loggerFactory; - } - - protected IHttpClient HttpClient { get; private set; } - - protected IUserManager UserManager { get; private set; } - - protected ILibraryManager LibraryManager { get; private set; } - - protected IIsoManager IsoManager { get; private set; } - - protected IMediaEncoder MediaEncoder { get; private set; } - - protected IFileSystem FileSystem { get; private set; } - - protected IDlnaManager DlnaManager { get; private set; } - - protected IDeviceManager DeviceManager { get; private set; } - - protected IMediaSourceManager MediaSourceManager { get; private set; } - - protected IJsonSerializer JsonSerializer { get; private set; } - - protected IAuthorizationContext AuthorizationContext { get; private set; } - - protected INetworkManager NetworkManager { get; private set; } - - public Task<object> Get(GetUniversalAudioStream request) - { - return GetUniversalStream(request, false); - } - - public Task<object> Head(GetUniversalAudioStream request) - { - return GetUniversalStream(request, true); - } - - private DeviceProfile GetDeviceProfile(GetUniversalAudioStream request) - { - var deviceProfile = new DeviceProfile(); - - var directPlayProfiles = new List<DirectPlayProfile>(); - - var containers = (request.Container ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - foreach (var container in containers) - { - var parts = container.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - - var audioCodecs = parts.Length == 1 ? null : string.Join(",", parts.Skip(1).ToArray()); - - directPlayProfiles.Add(new DirectPlayProfile - { - Type = DlnaProfileType.Audio, - Container = parts[0], - AudioCodec = audioCodecs - }); - } - - deviceProfile.DirectPlayProfiles = directPlayProfiles.ToArray(); - - deviceProfile.TranscodingProfiles = new[] - { - new TranscodingProfile - { - Type = DlnaProfileType.Audio, - Context = EncodingContext.Streaming, - Container = request.TranscodingContainer, - AudioCodec = request.AudioCodec, - Protocol = request.TranscodingProtocol, - BreakOnNonKeyFrames = request.BreakOnNonKeyFrames, - MaxAudioChannels = request.TranscodingAudioChannels?.ToString(CultureInfo.InvariantCulture) - } - }; - - var codecProfiles = new List<CodecProfile>(); - var conditions = new List<ProfileCondition>(); - - if (request.MaxAudioSampleRate.HasValue) - { - // codec profile - conditions.Add(new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioSampleRate, - Value = request.MaxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) - }); - } - - if (request.MaxAudioBitDepth.HasValue) - { - // codec profile - conditions.Add(new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioBitDepth, - Value = request.MaxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture) - }); - } - - if (request.MaxAudioChannels.HasValue) - { - // codec profile - conditions.Add(new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioChannels, - Value = request.MaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) - }); - } - - if (conditions.Count > 0) - { - // codec profile - codecProfiles.Add(new CodecProfile - { - Type = CodecType.Audio, - Container = request.Container, - Conditions = conditions.ToArray() - }); - } - - deviceProfile.CodecProfiles = codecProfiles.ToArray(); - - return deviceProfile; - } - - private async Task<object> GetUniversalStream(GetUniversalAudioStream request, bool isHeadRequest) - { - var deviceProfile = GetDeviceProfile(request); - - AuthorizationContext.GetAuthorizationInfo(Request).DeviceId = request.DeviceId; - - var mediaInfoService = new MediaInfoService( - _loggerFactory.CreateLogger<MediaInfoService>(), - ServerConfigurationManager, - ResultFactory, - MediaSourceManager, - DeviceManager, - LibraryManager, - NetworkManager, - MediaEncoder, - UserManager, - AuthorizationContext) - { - Request = Request - }; - - var playbackInfoResult = await mediaInfoService.GetPlaybackInfo(new GetPostedPlaybackInfo - { - Id = request.Id, - MaxAudioChannels = request.MaxAudioChannels, - MaxStreamingBitrate = request.MaxStreamingBitrate, - StartTimeTicks = request.StartTimeTicks, - UserId = request.UserId, - DeviceProfile = deviceProfile, - MediaSourceId = request.MediaSourceId - }).ConfigureAwait(false); - - var mediaSource = playbackInfoResult.MediaSources[0]; - - if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http) - { - if (request.EnableRedirection) - { - if (mediaSource.IsRemote && request.EnableRemoteMedia) - { - return ResultFactory.GetRedirectResult(mediaSource.Path); - } - } - } - - var isStatic = mediaSource.SupportsDirectStream; - - if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) - { - var service = new DynamicHlsService( - _loggerFactory.CreateLogger<DynamicHlsService>(), - ServerConfigurationManager, - ResultFactory, - UserManager, - LibraryManager, - IsoManager, - MediaEncoder, - FileSystem, - DlnaManager, - DeviceManager, - MediaSourceManager, - JsonSerializer, - AuthorizationContext, - NetworkManager, - _encodingHelper) - { - Request = Request - }; - - var transcodingProfile = deviceProfile.TranscodingProfiles[0]; - - // hls segment container can only be mpegts or fmp4 per ffmpeg documentation - // TODO: remove this when we switch back to the segment muxer - var supportedHLSContainers = new[] { "mpegts", "fmp4" }; - - var newRequest = new GetMasterHlsAudioPlaylist - { - AudioBitRate = isStatic ? (int?)null : Convert.ToInt32(Math.Min(request.MaxStreamingBitrate ?? 192000, int.MaxValue)), - AudioCodec = transcodingProfile.AudioCodec, - Container = ".m3u8", - DeviceId = request.DeviceId, - Id = request.Id, - MaxAudioChannels = request.MaxAudioChannels, - MediaSourceId = mediaSource.Id, - PlaySessionId = playbackInfoResult.PlaySessionId, - StartTimeTicks = request.StartTimeTicks, - Static = isStatic, - // fallback to mpegts if device reports some weird value unsupported by hls - SegmentContainer = Array.Exists(supportedHLSContainers, element => element == request.TranscodingContainer) ? request.TranscodingContainer : "mpegts", - AudioSampleRate = request.MaxAudioSampleRate, - MaxAudioBitDepth = request.MaxAudioBitDepth, - BreakOnNonKeyFrames = transcodingProfile.BreakOnNonKeyFrames, - TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()) - }; - - if (isHeadRequest) - { - return await service.Head(newRequest).ConfigureAwait(false); - } - - return await service.Get(newRequest).ConfigureAwait(false); - } - else - { - var service = new AudioService( - _loggerFactory.CreateLogger<AudioService>(), - ServerConfigurationManager, - ResultFactory, - HttpClient, - UserManager, - LibraryManager, - IsoManager, - MediaEncoder, - FileSystem, - DlnaManager, - DeviceManager, - MediaSourceManager, - JsonSerializer, - AuthorizationContext, - _encodingHelper) - { - Request = Request - }; - - var newRequest = new GetAudioStream - { - AudioBitRate = isStatic ? (int?)null : Convert.ToInt32(Math.Min(request.MaxStreamingBitrate ?? 192000, int.MaxValue)), - AudioCodec = request.AudioCodec, - Container = isStatic ? null : ("." + mediaSource.TranscodingContainer), - DeviceId = request.DeviceId, - Id = request.Id, - MaxAudioChannels = request.MaxAudioChannels, - MediaSourceId = mediaSource.Id, - PlaySessionId = playbackInfoResult.PlaySessionId, - StartTimeTicks = request.StartTimeTicks, - Static = isStatic, - AudioSampleRate = request.MaxAudioSampleRate, - MaxAudioBitDepth = request.MaxAudioBitDepth, - TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()) - }; - - if (isHeadRequest) - { - return await service.Head(newRequest).ConfigureAwait(false); - } - - return await service.Get(newRequest).ConfigureAwait(false); - } - } - } -} diff --git a/MediaBrowser.Api/PlaylistService.cs b/MediaBrowser.Api/PlaylistService.cs deleted file mode 100644 index 5513c0892..000000000 --- a/MediaBrowser.Api/PlaylistService.cs +++ /dev/null @@ -1,216 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Playlists; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Playlists; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Playlists", "POST", Summary = "Creates a new playlist")] - public class CreatePlaylist : IReturn<PlaylistCreationResult> - { - [ApiMember(Name = "Name", Description = "The name of the new playlist.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Name { get; set; } - - [ApiMember(Name = "Ids", Description = "Item Ids to add to the playlist", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)] - public string Ids { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid UserId { get; set; } - - [ApiMember(Name = "MediaType", Description = "The playlist media type", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string MediaType { get; set; } - } - - [Route("/Playlists/{Id}/Items", "POST", Summary = "Adds items to a playlist")] - public class AddToPlaylist : IReturnVoid - { - [ApiMember(Name = "Ids", Description = "Item id, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Ids { get; set; } - - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public Guid UserId { get; set; } - } - - [Route("/Playlists/{Id}/Items/{ItemId}/Move/{NewIndex}", "POST", Summary = "Moves a playlist item")] - public class MoveItem : IReturnVoid - { - [ApiMember(Name = "ItemId", Description = "ItemId", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemId { get; set; } - - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "NewIndex", Description = "NewIndex", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public int NewIndex { get; set; } - } - - [Route("/Playlists/{Id}/Items", "DELETE", Summary = "Removes items from a playlist")] - public class RemoveFromPlaylist : IReturnVoid - { - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - - [ApiMember(Name = "EntryIds", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string EntryIds { get; set; } - } - - [Route("/Playlists/{Id}/Items", "GET", Summary = "Gets the original items of a playlist")] - public class GetPlaylistItems : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - } - - [Authenticated] - public class PlaylistService : BaseApiService - { - private readonly IPlaylistManager _playlistManager; - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IAuthorizationContext _authContext; - - public PlaylistService( - ILogger<PlaylistService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IDtoService dtoService, - IPlaylistManager playlistManager, - IUserManager userManager, - ILibraryManager libraryManager, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _dtoService = dtoService; - _playlistManager = playlistManager; - _userManager = userManager; - _libraryManager = libraryManager; - _authContext = authContext; - } - - public void Post(MoveItem request) - { - _playlistManager.MoveItem(request.Id, request.ItemId, request.NewIndex); - } - - public async Task<object> Post(CreatePlaylist request) - { - var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest - { - Name = request.Name, - ItemIdList = GetGuids(request.Ids), - UserId = request.UserId, - MediaType = request.MediaType - }).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public void Post(AddToPlaylist request) - { - _playlistManager.AddToPlaylist(request.Id, GetGuids(request.Ids), request.UserId); - } - - public void Delete(RemoveFromPlaylist request) - { - _playlistManager.RemoveFromPlaylist(request.Id, request.EntryIds.Split(',')); - } - - public object Get(GetPlaylistItems request) - { - var playlist = (Playlist)_libraryManager.GetItemById(request.Id); - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var items = playlist.GetManageableItems().ToArray(); - - var count = items.Length; - - if (request.StartIndex.HasValue) - { - items = items.Skip(request.StartIndex.Value).ToArray(); - } - - if (request.Limit.HasValue) - { - items = items.Take(request.Limit.Value).ToArray(); - } - - var dtoOptions = GetDtoOptions(_authContext, request); - - var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); - - for (int index = 0; index < dtos.Count; index++) - { - dtos[index].PlaylistItemId = items[index].Item1.Id; - } - - var result = new QueryResult<BaseItemDto> - { - Items = dtos, - TotalRecordCount = count - }; - - return ToOptimizedResult(result); - } - } -} diff --git a/MediaBrowser.Api/PluginService.cs b/MediaBrowser.Api/PluginService.cs deleted file mode 100644 index 7d976ceaa..000000000 --- a/MediaBrowser.Api/PluginService.cs +++ /dev/null @@ -1,277 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common; -using MediaBrowser.Common.Plugins; -using MediaBrowser.Common.Updates; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Plugins; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class Plugins. - /// </summary> - [Route("/Plugins", "GET", Summary = "Gets a list of currently installed plugins")] - [Authenticated] - public class GetPlugins : IReturn<PluginInfo[]> - { - public bool? IsAppStoreEnabled { get; set; } - } - - /// <summary> - /// Class UninstallPlugin. - /// </summary> - [Route("/Plugins/{Id}", "DELETE", Summary = "Uninstalls a plugin")] - [Authenticated(Roles = "Admin")] - public class UninstallPlugin : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Plugin Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - /// <summary> - /// Class GetPluginConfiguration. - /// </summary> - [Route("/Plugins/{Id}/Configuration", "GET", Summary = "Gets a plugin's configuration")] - [Authenticated] - public class GetPluginConfiguration - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Plugin Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - /// <summary> - /// Class UpdatePluginConfiguration. - /// </summary> - [Route("/Plugins/{Id}/Configuration", "POST", Summary = "Updates a plugin's configuration")] - [Authenticated] - public class UpdatePluginConfiguration : IRequiresRequestStream, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Plugin Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// The raw Http Request Input Stream. - /// </summary> - /// <value>The request stream.</value> - public Stream RequestStream { get; set; } - } - - // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins, - // delete all these registration endpoints. They are only kept for compatibility. - [Route("/Registrations/{Name}", "GET", Summary = "Gets registration status for a feature", IsHidden = true)] - [Authenticated] - public class GetRegistration : IReturn<RegistrationInfo> - { - [ApiMember(Name = "Name", Description = "Feature Name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - } - - /// <summary> - /// Class GetPluginSecurityInfo. - /// </summary> - [Route("/Plugins/SecurityInfo", "GET", Summary = "Gets plugin registration information", IsHidden = true)] - [Authenticated] - public class GetPluginSecurityInfo : IReturn<PluginSecurityInfo> - { - } - - /// <summary> - /// Class UpdatePluginSecurityInfo. - /// </summary> - [Route("/Plugins/SecurityInfo", "POST", Summary = "Updates plugin registration information", IsHidden = true)] - [Authenticated(Roles = "Admin")] - public class UpdatePluginSecurityInfo : PluginSecurityInfo, IReturnVoid - { - } - - [Route("/Plugins/RegistrationRecords/{Name}", "GET", Summary = "Gets registration status for a feature", IsHidden = true)] - [Authenticated] - public class GetRegistrationStatus - { - [ApiMember(Name = "Name", Description = "Feature Name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - } - - // TODO these two classes are only kept for compability with paid plugins and should be removed - public class RegistrationInfo - { - public string Name { get; set; } - - public DateTime ExpirationDate { get; set; } - - public bool IsTrial { get; set; } - - public bool IsRegistered { get; set; } - } - - public class MBRegistrationRecord - { - public DateTime ExpirationDate { get; set; } - - public bool IsRegistered { get; set; } - - public bool RegChecked { get; set; } - - public bool RegError { get; set; } - - public bool TrialVersion { get; set; } - - public bool IsValid { get; set; } - } - - public class PluginSecurityInfo - { - public string SupporterKey { get; set; } - - public bool IsMBSupporter { get; set; } - } - /// <summary> - /// Class PluginsService. - /// </summary> - public class PluginService : BaseApiService - { - /// <summary> - /// The _json serializer. - /// </summary> - private readonly IJsonSerializer _jsonSerializer; - - /// <summary> - /// The _app host. - /// </summary> - private readonly IApplicationHost _appHost; - private readonly IInstallationManager _installationManager; - - public PluginService( - ILogger<PluginService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IJsonSerializer jsonSerializer, - IApplicationHost appHost, - IInstallationManager installationManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _appHost = appHost; - _installationManager = installationManager; - _jsonSerializer = jsonSerializer; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetRegistrationStatus request) - { - var record = new MBRegistrationRecord - { - IsRegistered = true, - RegChecked = true, - TrialVersion = false, - IsValid = true, - RegError = false - }; - - return ToOptimizedResult(record); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetPlugins request) - { - var result = _appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo()).ToArray(); - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetPluginConfiguration request) - { - var guid = new Guid(request.Id); - var plugin = _appHost.Plugins.First(p => p.Id == guid) as IHasPluginConfiguration; - - return ToOptimizedResult(plugin.Configuration); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetPluginSecurityInfo request) - { - var result = new PluginSecurityInfo - { - IsMBSupporter = true, - SupporterKey = "IAmTotallyLegit" - }; - - return ToOptimizedResult(result); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(UpdatePluginSecurityInfo request) - { - return Task.CompletedTask; - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public async Task Post(UpdatePluginConfiguration request) - { - // We need to parse this manually because we told service stack not to with IRequiresRequestStream - // https://code.google.com/p/servicestack/source/browse/trunk/Common/ServiceStack.Text/ServiceStack.Text/Controller/PathInfo.cs - var id = Guid.Parse(GetPathValue(1)); - - if (!(_appHost.Plugins.First(p => p.Id == id) is IHasPluginConfiguration plugin)) - { - throw new FileNotFoundException(); - } - - var configuration = (await _jsonSerializer.DeserializeFromStreamAsync(request.RequestStream, plugin.ConfigurationType).ConfigureAwait(false)) as BasePluginConfiguration; - - plugin.UpdateConfiguration(configuration); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Delete(UninstallPlugin request) - { - var guid = new Guid(request.Id); - var plugin = _appHost.Plugins.First(p => p.Id == guid); - - _installationManager.UninstallPlugin(plugin); - } - } -} diff --git a/MediaBrowser.Api/Properties/AssemblyInfo.cs b/MediaBrowser.Api/Properties/AssemblyInfo.cs deleted file mode 100644 index 078af3e30..000000000 --- a/MediaBrowser.Api/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Reflection; -using System.Resources; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("MediaBrowser.Api")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Jellyfin Project")] -[assembly: AssemblyProduct("Jellyfin Server")] -[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: NeutralResourcesLanguage("en")] -[assembly: InternalsVisibleTo("Jellyfin.Api.Tests")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] diff --git a/MediaBrowser.Api/ScheduledTasks/ScheduledTaskService.cs b/MediaBrowser.Api/ScheduledTasks/ScheduledTaskService.cs deleted file mode 100644 index 86b00316a..000000000 --- a/MediaBrowser.Api/ScheduledTasks/ScheduledTaskService.cs +++ /dev/null @@ -1,234 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.ScheduledTasks -{ - /// <summary> - /// Class GetScheduledTask. - /// </summary> - [Route("/ScheduledTasks/{Id}", "GET", Summary = "Gets a scheduled task, by Id")] - public class GetScheduledTask : IReturn<TaskInfo> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - /// <summary> - /// Class GetScheduledTasks. - /// </summary> - [Route("/ScheduledTasks", "GET", Summary = "Gets scheduled tasks")] - public class GetScheduledTasks : IReturn<TaskInfo[]> - { - [ApiMember(Name = "IsHidden", Description = "Optional filter tasks that are hidden, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsHidden { get; set; } - - [ApiMember(Name = "IsEnabled", Description = "Optional filter tasks that are enabled, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsEnabled { get; set; } - } - - /// <summary> - /// Class StartScheduledTask. - /// </summary> - [Route("/ScheduledTasks/Running/{Id}", "POST", Summary = "Starts a scheduled task")] - public class StartScheduledTask : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - /// <summary> - /// Class StopScheduledTask. - /// </summary> - [Route("/ScheduledTasks/Running/{Id}", "DELETE", Summary = "Stops a scheduled task")] - public class StopScheduledTask : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - /// <summary> - /// Class UpdateScheduledTaskTriggers. - /// </summary> - [Route("/ScheduledTasks/{Id}/Triggers", "POST", Summary = "Updates the triggers for a scheduled task")] - public class UpdateScheduledTaskTriggers : List<TaskTriggerInfo>, IReturnVoid - { - /// <summary> - /// Gets or sets the task id. - /// </summary> - /// <value>The task id.</value> - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - /// <summary> - /// Class ScheduledTasksService. - /// </summary> - [Authenticated(Roles = "Admin")] - public class ScheduledTaskService : BaseApiService - { - /// <summary> - /// The task manager. - /// </summary> - private readonly ITaskManager _taskManager; - - /// <summary> - /// Initializes a new instance of the <see cref="ScheduledTaskService" /> class. - /// </summary> - /// <param name="taskManager">The task manager.</param> - /// <exception cref="ArgumentNullException">taskManager</exception> - public ScheduledTaskService( - ILogger<ScheduledTaskService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ITaskManager taskManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _taskManager = taskManager; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>IEnumerable{TaskInfo}.</returns> - public object Get(GetScheduledTasks request) - { - IEnumerable<IScheduledTaskWorker> result = _taskManager.ScheduledTasks - .OrderBy(i => i.Name); - - if (request.IsHidden.HasValue) - { - var val = request.IsHidden.Value; - - result = result.Where(i => - { - var isHidden = false; - - if (i.ScheduledTask is IConfigurableScheduledTask configurableTask) - { - isHidden = configurableTask.IsHidden; - } - - return isHidden == val; - }); - } - - if (request.IsEnabled.HasValue) - { - var val = request.IsEnabled.Value; - - result = result.Where(i => - { - var isEnabled = true; - - if (i.ScheduledTask is IConfigurableScheduledTask configurableTask) - { - isEnabled = configurableTask.IsEnabled; - } - - return isEnabled == val; - }); - } - - var infos = result - .Select(ScheduledTaskHelpers.GetTaskInfo) - .ToArray(); - - return ToOptimizedResult(infos); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>IEnumerable{TaskInfo}.</returns> - /// <exception cref="ResourceNotFoundException">Task not found</exception> - public object Get(GetScheduledTask request) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(i => string.Equals(i.Id, request.Id)); - - if (task == null) - { - throw new ResourceNotFoundException("Task not found"); - } - - var result = ScheduledTaskHelpers.GetTaskInfo(task); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <exception cref="ResourceNotFoundException">Task not found</exception> - public void Post(StartScheduledTask request) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(i => string.Equals(i.Id, request.Id)); - - if (task == null) - { - throw new ResourceNotFoundException("Task not found"); - } - - _taskManager.Execute(task, new TaskOptions()); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <exception cref="ResourceNotFoundException">Task not found</exception> - public void Delete(StopScheduledTask request) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(i => string.Equals(i.Id, request.Id)); - - if (task == null) - { - throw new ResourceNotFoundException("Task not found"); - } - - _taskManager.Cancel(task); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <exception cref="ResourceNotFoundException">Task not found</exception> - public void Post(UpdateScheduledTaskTriggers request) - { - // We need to parse this manually because we told service stack not to with IRequiresRequestStream - // https://code.google.com/p/servicestack/source/browse/trunk/Common/ServiceStack.Text/ServiceStack.Text/Controller/PathInfo.cs - var id = GetPathValue(1).ToString(); - - var task = _taskManager.ScheduledTasks.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.Ordinal)); - - if (task == null) - { - throw new ResourceNotFoundException("Task not found"); - } - - task.Triggers = request.ToArray(); - } - } -} diff --git a/MediaBrowser.Api/SearchService.cs b/MediaBrowser.Api/SearchService.cs deleted file mode 100644 index 64ee69300..000000000 --- a/MediaBrowser.Api/SearchService.cs +++ /dev/null @@ -1,332 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Search; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class GetSearchHints. - /// </summary> - [Route("/Search/Hints", "GET", Summary = "Gets search hints based on a search term")] - public class GetSearchHints : IReturn<SearchHintResult> - { - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Supply a user id to search within a user's library or omit to search all.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Search characters used to find items. - /// </summary> - /// <value>The index by.</value> - [ApiMember(Name = "SearchTerm", Description = "The search term to filter on", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SearchTerm { get; set; } - - - [ApiMember(Name = "IncludePeople", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludePeople { get; set; } - - [ApiMember(Name = "IncludeMedia", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludeMedia { get; set; } - - [ApiMember(Name = "IncludeGenres", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludeGenres { get; set; } - - [ApiMember(Name = "IncludeStudios", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludeStudios { get; set; } - - [ApiMember(Name = "IncludeArtists", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludeArtists { get; set; } - - [ApiMember(Name = "IncludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string IncludeItemTypes { get; set; } - - [ApiMember(Name = "ExcludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string ExcludeItemTypes { get; set; } - - [ApiMember(Name = "MediaTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string MediaTypes { get; set; } - - public string ParentId { get; set; } - - [ApiMember(Name = "IsMovie", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsMovie { get; set; } - - [ApiMember(Name = "IsSeries", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSeries { get; set; } - - [ApiMember(Name = "IsNews", Description = "Optional filter for news.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsNews { get; set; } - - [ApiMember(Name = "IsKids", Description = "Optional filter for kids.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsKids { get; set; } - - [ApiMember(Name = "IsSports", Description = "Optional filter for sports.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSports { get; set; } - - public GetSearchHints() - { - IncludeArtists = true; - IncludeGenres = true; - IncludeMedia = true; - IncludePeople = true; - IncludeStudios = true; - } - } - - /// <summary> - /// Class SearchService. - /// </summary> - [Authenticated] - public class SearchService : BaseApiService - { - /// <summary> - /// The _search engine. - /// </summary> - private readonly ISearchEngine _searchEngine; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IImageProcessor _imageProcessor; - - /// <summary> - /// Initializes a new instance of the <see cref="SearchService" /> class. - /// </summary> - /// <param name="searchEngine">The search engine.</param> - /// <param name="libraryManager">The library manager.</param> - /// <param name="dtoService">The dto service.</param> - /// <param name="imageProcessor">The image processor.</param> - public SearchService( - ILogger<SearchService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ISearchEngine searchEngine, - ILibraryManager libraryManager, - IDtoService dtoService, - IImageProcessor imageProcessor) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _searchEngine = searchEngine; - _libraryManager = libraryManager; - _dtoService = dtoService; - _imageProcessor = imageProcessor; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetSearchHints request) - { - var result = GetSearchHintsAsync(request); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the search hints async. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{IEnumerable{SearchHintResult}}.</returns> - private SearchHintResult GetSearchHintsAsync(GetSearchHints request) - { - var result = _searchEngine.GetSearchHints(new SearchQuery - { - Limit = request.Limit, - SearchTerm = request.SearchTerm, - IncludeArtists = request.IncludeArtists, - IncludeGenres = request.IncludeGenres, - IncludeMedia = request.IncludeMedia, - IncludePeople = request.IncludePeople, - IncludeStudios = request.IncludeStudios, - StartIndex = request.StartIndex, - UserId = request.UserId, - IncludeItemTypes = ApiEntryPoint.Split(request.IncludeItemTypes, ',', true), - ExcludeItemTypes = ApiEntryPoint.Split(request.ExcludeItemTypes, ',', true), - MediaTypes = ApiEntryPoint.Split(request.MediaTypes, ',', true), - ParentId = request.ParentId, - - IsKids = request.IsKids, - IsMovie = request.IsMovie, - IsNews = request.IsNews, - IsSeries = request.IsSeries, - IsSports = request.IsSports - }); - - return new SearchHintResult - { - TotalRecordCount = result.TotalRecordCount, - - SearchHints = result.Items.Select(GetSearchHintResult).ToArray() - }; - } - - /// <summary> - /// Gets the search hint result. - /// </summary> - /// <param name="hintInfo">The hint info.</param> - /// <returns>SearchHintResult.</returns> - private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) - { - var item = hintInfo.Item; - - var result = new SearchHint - { - Name = item.Name, - IndexNumber = item.IndexNumber, - ParentIndexNumber = item.ParentIndexNumber, - Id = item.Id, - Type = item.GetClientTypeName(), - MediaType = item.MediaType, - MatchedTerm = hintInfo.MatchedTerm, - RunTimeTicks = item.RunTimeTicks, - ProductionYear = item.ProductionYear, - ChannelId = item.ChannelId, - EndDate = item.EndDate - }; - - // legacy - result.ItemId = result.Id; - - if (item.IsFolder) - { - result.IsFolder = true; - } - - var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); - - if (primaryImageTag != null) - { - result.PrimaryImageTag = primaryImageTag; - result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); - } - - SetThumbImageInfo(result, item); - SetBackdropImageInfo(result, item); - - switch (item) - { - case IHasSeries hasSeries: - result.Series = hasSeries.SeriesName; - break; - case LiveTvProgram program: - result.StartDate = program.StartDate; - break; - case Series series: - if (series.Status.HasValue) - { - result.Status = series.Status.Value.ToString(); - } - - break; - case MusicAlbum album: - result.Artists = album.Artists; - result.AlbumArtist = album.AlbumArtist; - break; - case Audio song: - result.AlbumArtist = song.AlbumArtists.FirstOrDefault(); - result.Artists = song.Artists; - - MusicAlbum musicAlbum = song.AlbumEntity; - - if (musicAlbum != null) - { - result.Album = musicAlbum.Name; - result.AlbumId = musicAlbum.Id; - } - else - { - result.Album = song.Album; - } - - break; - } - - if (!item.ChannelId.Equals(Guid.Empty)) - { - var channel = _libraryManager.GetItemById(item.ChannelId); - result.ChannelName = channel?.Name; - } - - return result; - } - - private void SetThumbImageInfo(SearchHint hint, BaseItem item) - { - var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; - - if (itemWithImage == null && item is Episode) - { - itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb); - } - - if (itemWithImage == null) - { - itemWithImage = GetParentWithImage<BaseItem>(item, ImageType.Thumb); - } - - if (itemWithImage != null) - { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); - - if (tag != null) - { - hint.ThumbImageTag = tag; - hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); - } - } - } - - private void SetBackdropImageInfo(SearchHint hint, BaseItem item) - { - var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) - ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop); - - if (itemWithImage != null) - { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); - - if (tag != null) - { - hint.BackdropImageTag = tag; - hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); - } - } - } - - private T GetParentWithImage<T>(BaseItem item, ImageType type) - where T : BaseItem - { - return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type)); - } - } -} diff --git a/MediaBrowser.Api/Sessions/ApiKeyService.cs b/MediaBrowser.Api/Sessions/ApiKeyService.cs deleted file mode 100644 index 5102ce0a7..000000000 --- a/MediaBrowser.Api/Sessions/ApiKeyService.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Globalization; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Sessions -{ - [Route("/Auth/Keys", "GET")] - [Authenticated(Roles = "Admin")] - public class GetKeys - { - } - - [Route("/Auth/Keys/{Key}", "DELETE")] - [Authenticated(Roles = "Admin")] - public class RevokeKey - { - [ApiMember(Name = "Key", Description = "Authentication key", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Key { get; set; } - } - - [Route("/Auth/Keys", "POST")] - [Authenticated(Roles = "Admin")] - public class CreateKey - { - [ApiMember(Name = "App", Description = "Name of the app using the authentication key", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string App { get; set; } - } - - public class ApiKeyService : BaseApiService - { - private readonly ISessionManager _sessionManager; - - private readonly IAuthenticationRepository _authRepo; - - private readonly IServerApplicationHost _appHost; - - public ApiKeyService( - ILogger<ApiKeyService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ISessionManager sessionManager, - IServerApplicationHost appHost, - IAuthenticationRepository authRepo) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _sessionManager = sessionManager; - _authRepo = authRepo; - _appHost = appHost; - } - - public void Delete(RevokeKey request) - { - _sessionManager.RevokeToken(request.Key); - } - - public void Post(CreateKey request) - { - _authRepo.Create(new AuthenticationInfo - { - AppName = request.App, - AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), - DateCreated = DateTime.UtcNow, - DeviceId = _appHost.SystemId, - DeviceName = _appHost.FriendlyName, - AppVersion = _appHost.ApplicationVersionString - }); - } - - public object Get(GetKeys request) - { - var result = _authRepo.Get(new AuthenticationInfoQuery - { - HasUser = false - }); - - return result; - } - } -} diff --git a/MediaBrowser.Api/Sessions/SessionService.cs b/MediaBrowser.Api/Sessions/SessionService.cs deleted file mode 100644 index 50adc5698..000000000 --- a/MediaBrowser.Api/Sessions/SessionService.cs +++ /dev/null @@ -1,499 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.Session; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Sessions -{ - /// <summary> - /// Class GetSessions. - /// </summary> - [Route("/Sessions", "GET", Summary = "Gets a list of sessions")] - [Authenticated] - public class GetSessions : IReturn<SessionInfo[]> - { - [ApiMember(Name = "ControllableByUserId", Description = "Filter by sessions that a given user is allowed to remote control.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid ControllableByUserId { get; set; } - - [ApiMember(Name = "DeviceId", Description = "Filter by device Id.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string DeviceId { get; set; } - - public int? ActiveWithinSeconds { get; set; } - } - - /// <summary> - /// Class DisplayContent. - /// </summary> - [Route("/Sessions/{Id}/Viewing", "POST", Summary = "Instructs a session to browse to an item or view")] - [Authenticated] - public class DisplayContent : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Artist, Genre, Studio, Person, or any kind of BaseItem. - /// </summary> - /// <value>The type of the item.</value> - [ApiMember(Name = "ItemType", Description = "The type of item to browse to.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemType { get; set; } - - /// <summary> - /// Artist name, genre name, item Id, etc. - /// </summary> - /// <value>The item identifier.</value> - [ApiMember(Name = "ItemId", Description = "The Id of the item.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemId { get; set; } - - /// <summary> - /// Gets or sets the name of the item. - /// </summary> - /// <value>The name of the item.</value> - [ApiMember(Name = "ItemName", Description = "The name of the item.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemName { get; set; } - } - - [Route("/Sessions/{Id}/Playing", "POST", Summary = "Instructs a session to play an item")] - [Authenticated] - public class Play : PlayRequest - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Sessions/{Id}/Playing/{Command}", "POST", Summary = "Issues a playstate command to a client")] - [Authenticated] - public class SendPlaystateCommand : PlaystateRequest, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Sessions/{Id}/System/{Command}", "POST", Summary = "Issues a system command to a client")] - [Authenticated] - public class SendSystemCommand : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the command. - /// </summary> - /// <value>The play command.</value> - [ApiMember(Name = "Command", Description = "The command to send.", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Command { get; set; } - } - - [Route("/Sessions/{Id}/Command/{Command}", "POST", Summary = "Issues a system command to a client")] - [Authenticated] - public class SendGeneralCommand : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the command. - /// </summary> - /// <value>The play command.</value> - [ApiMember(Name = "Command", Description = "The command to send.", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Command { get; set; } - } - - [Route("/Sessions/{Id}/Command", "POST", Summary = "Issues a system command to a client")] - [Authenticated] - public class SendFullGeneralCommand : GeneralCommand, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Sessions/{Id}/Message", "POST", Summary = "Issues a command to a client to display a message to the user")] - [Authenticated] - public class SendMessageCommand : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "Text", Description = "The message text.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Text { get; set; } - - [ApiMember(Name = "Header", Description = "The message header.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Header { get; set; } - - [ApiMember(Name = "TimeoutMs", Description = "The message timeout. If omitted the user will have to confirm viewing the message.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public long? TimeoutMs { get; set; } - } - - [Route("/Sessions/{Id}/Users/{UserId}", "POST", Summary = "Adds an additional user to a session")] - [Authenticated] - public class AddUserToSession : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "UserId Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string UserId { get; set; } - } - - [Route("/Sessions/{Id}/Users/{UserId}", "DELETE", Summary = "Removes an additional user from a session")] - [Authenticated] - public class RemoveUserFromSession : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string UserId { get; set; } - } - - [Route("/Sessions/Capabilities", "POST", Summary = "Updates capabilities for a device")] - [Authenticated] - public class PostCapabilities : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "PlayableMediaTypes", Description = "A list of playable media types, comma delimited. Audio, Video, Book, Photo.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string PlayableMediaTypes { get; set; } - - [ApiMember(Name = "SupportedCommands", Description = "A list of supported remote control commands, comma delimited", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string SupportedCommands { get; set; } - - [ApiMember(Name = "SupportsMediaControl", Description = "Determines whether media can be played remotely.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool SupportsMediaControl { get; set; } - - [ApiMember(Name = "SupportsSync", Description = "Determines whether sync is supported.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool SupportsSync { get; set; } - - [ApiMember(Name = "SupportsPersistentIdentifier", Description = "Determines whether the device supports a unique identifier.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool SupportsPersistentIdentifier { get; set; } - - public PostCapabilities() - { - SupportsPersistentIdentifier = true; - } - } - - [Route("/Sessions/Capabilities/Full", "POST", Summary = "Updates capabilities for a device")] - [Authenticated] - public class PostFullCapabilities : ClientCapabilities, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Sessions/Viewing", "POST", Summary = "Reports that a session is viewing an item")] - [Authenticated] - public class ReportViewing : IReturnVoid - { - [ApiMember(Name = "SessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string SessionId { get; set; } - - [ApiMember(Name = "ItemId", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemId { get; set; } - } - - [Route("/Sessions/Logout", "POST", Summary = "Reports that a session has ended")] - [Authenticated] - public class ReportSessionEnded : IReturnVoid - { - } - - [Route("/Auth/Providers", "GET")] - [Authenticated(Roles = "Admin")] - public class GetAuthProviders : IReturn<NameIdPair[]> - { - } - - [Route("/Auth/PasswordResetProviders", "GET")] - [Authenticated(Roles = "Admin")] - public class GetPasswordResetProviders : IReturn<NameIdPair[]> - { - } - - /// <summary> - /// Class SessionsService. - /// </summary> - public class SessionService : BaseApiService - { - /// <summary> - /// The session manager. - /// </summary> - private readonly ISessionManager _sessionManager; - - private readonly IUserManager _userManager; - private readonly IAuthorizationContext _authContext; - private readonly IDeviceManager _deviceManager; - private readonly ISessionContext _sessionContext; - - public SessionService( - ILogger<SessionService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ISessionManager sessionManager, - IUserManager userManager, - IAuthorizationContext authContext, - IDeviceManager deviceManager, - ISessionContext sessionContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _sessionManager = sessionManager; - _userManager = userManager; - _authContext = authContext; - _deviceManager = deviceManager; - _sessionContext = sessionContext; - } - - public object Get(GetAuthProviders request) - { - return _userManager.GetAuthenticationProviders(); - } - - public object Get(GetPasswordResetProviders request) - { - return _userManager.GetPasswordResetProviders(); - } - - public void Post(ReportSessionEnded request) - { - var auth = _authContext.GetAuthorizationInfo(Request); - - _sessionManager.Logout(auth.Token); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetSessions request) - { - var result = _sessionManager.Sessions; - - if (!string.IsNullOrEmpty(request.DeviceId)) - { - result = result.Where(i => string.Equals(i.DeviceId, request.DeviceId, StringComparison.OrdinalIgnoreCase)); - } - - if (!request.ControllableByUserId.Equals(Guid.Empty)) - { - result = result.Where(i => i.SupportsRemoteControl); - - var user = _userManager.GetUserById(request.ControllableByUserId); - - if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) - { - result = result.Where(i => i.UserId.Equals(Guid.Empty) || i.ContainsUser(request.ControllableByUserId)); - } - - if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) - { - result = result.Where(i => !i.UserId.Equals(Guid.Empty)); - } - - if (request.ActiveWithinSeconds.HasValue && request.ActiveWithinSeconds.Value > 0) - { - var minActiveDate = DateTime.UtcNow.AddSeconds(0 - request.ActiveWithinSeconds.Value); - result = result.Where(i => i.LastActivityDate >= minActiveDate); - } - - result = result.Where(i => - { - var deviceId = i.DeviceId; - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - if (!_deviceManager.CanAccessDevice(user, deviceId)) - { - return false; - } - } - - return true; - }); - } - - return ToOptimizedResult(result.ToArray()); - } - - public Task Post(SendPlaystateCommand request) - { - return _sessionManager.SendPlaystateCommand(GetSession(_sessionContext).Id, request.Id, request, CancellationToken.None); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(DisplayContent request) - { - var command = new BrowseRequest - { - ItemId = request.ItemId, - ItemName = request.ItemName, - ItemType = request.ItemType - }; - - return _sessionManager.SendBrowseCommand(GetSession(_sessionContext).Id, request.Id, command, CancellationToken.None); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(SendSystemCommand request) - { - var name = request.Command; - if (Enum.TryParse(name, true, out GeneralCommandType commandType)) - { - name = commandType.ToString(); - } - - var currentSession = GetSession(_sessionContext); - var command = new GeneralCommand - { - Name = name, - ControllingUserId = currentSession.UserId - }; - - return _sessionManager.SendGeneralCommand(currentSession.Id, request.Id, command, CancellationToken.None); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(SendMessageCommand request) - { - var command = new MessageCommand - { - Header = string.IsNullOrEmpty(request.Header) ? "Message from Server" : request.Header, - TimeoutMs = request.TimeoutMs, - Text = request.Text - }; - - return _sessionManager.SendMessageCommand(GetSession(_sessionContext).Id, request.Id, command, CancellationToken.None); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(Play request) - { - return _sessionManager.SendPlayCommand(GetSession(_sessionContext).Id, request.Id, request, CancellationToken.None); - } - - public Task Post(SendGeneralCommand request) - { - var currentSession = GetSession(_sessionContext); - - var command = new GeneralCommand - { - Name = request.Command, - ControllingUserId = currentSession.UserId - }; - - return _sessionManager.SendGeneralCommand(currentSession.Id, request.Id, command, CancellationToken.None); - } - - public Task Post(SendFullGeneralCommand request) - { - var currentSession = GetSession(_sessionContext); - - request.ControllingUserId = currentSession.UserId; - - return _sessionManager.SendGeneralCommand(currentSession.Id, request.Id, request, CancellationToken.None); - } - - public void Post(AddUserToSession request) - { - _sessionManager.AddAdditionalUser(request.Id, new Guid(request.UserId)); - } - - public void Delete(RemoveUserFromSession request) - { - _sessionManager.RemoveAdditionalUser(request.Id, new Guid(request.UserId)); - } - - public void Post(PostCapabilities request) - { - if (string.IsNullOrWhiteSpace(request.Id)) - { - request.Id = GetSession(_sessionContext).Id; - } - - _sessionManager.ReportCapabilities(request.Id, new ClientCapabilities - { - PlayableMediaTypes = SplitValue(request.PlayableMediaTypes, ','), - SupportedCommands = SplitValue(request.SupportedCommands, ','), - SupportsMediaControl = request.SupportsMediaControl, - SupportsSync = request.SupportsSync, - SupportsPersistentIdentifier = request.SupportsPersistentIdentifier - }); - } - - public void Post(PostFullCapabilities request) - { - if (string.IsNullOrWhiteSpace(request.Id)) - { - request.Id = GetSession(_sessionContext).Id; - } - - _sessionManager.ReportCapabilities(request.Id, request); - } - - public void Post(ReportViewing request) - { - request.SessionId = GetSession(_sessionContext).Id; - - _sessionManager.ReportNowViewingItem(request.SessionId, request.ItemId); - } - } -} diff --git a/MediaBrowser.Api/Subtitles/SubtitleService.cs b/MediaBrowser.Api/Subtitles/SubtitleService.cs deleted file mode 100644 index a70da8e56..000000000 --- a/MediaBrowser.Api/Subtitles/SubtitleService.cs +++ /dev/null @@ -1,302 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Controller.Subtitles; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using MimeTypes = MediaBrowser.Model.Net.MimeTypes; - -namespace MediaBrowser.Api.Subtitles -{ - [Route("/Videos/{Id}/Subtitles/{Index}", "DELETE", Summary = "Deletes an external subtitle file")] - [Authenticated(Roles = "Admin")] - public class DeleteSubtitle - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - - [ApiMember(Name = "Index", Description = "The subtitle stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "DELETE")] - public int Index { get; set; } - } - - [Route("/Items/{Id}/RemoteSearch/Subtitles/{Language}", "GET")] - [Authenticated] - public class SearchRemoteSubtitles : IReturn<RemoteSubtitleInfo[]> - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - - [ApiMember(Name = "Language", Description = "Language", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Language { get; set; } - - public bool? IsPerfectMatch { get; set; } - } - - [Route("/Items/{Id}/RemoteSearch/Subtitles/{SubtitleId}", "POST")] - [Authenticated] - public class DownloadRemoteSubtitles : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - - [ApiMember(Name = "SubtitleId", Description = "SubtitleId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string SubtitleId { get; set; } - } - - [Route("/Providers/Subtitles/Subtitles/{Id}", "GET")] - [Authenticated] - public class GetRemoteSubtitles : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}", "GET", Summary = "Gets subtitles in a specified format.")] - [Route("/Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/{StartPositionTicks}/Stream.{Format}", "GET", Summary = "Gets subtitles in a specified format.")] - public class GetSubtitle - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - - [ApiMember(Name = "MediaSourceId", Description = "MediaSourceId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string MediaSourceId { get; set; } - - [ApiMember(Name = "Index", Description = "The subtitle stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")] - public int Index { get; set; } - - [ApiMember(Name = "Format", Description = "Format", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Format { get; set; } - - [ApiMember(Name = "StartPositionTicks", Description = "StartPositionTicks", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public long StartPositionTicks { get; set; } - - [ApiMember(Name = "EndPositionTicks", Description = "EndPositionTicks", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public long? EndPositionTicks { get; set; } - - [ApiMember(Name = "CopyTimestamps", Description = "CopyTimestamps", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool CopyTimestamps { get; set; } - - public bool AddVttTimeMap { get; set; } - } - - [Route("/Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/subtitles.m3u8", "GET", Summary = "Gets an HLS subtitle playlist.")] - [Authenticated] - public class GetSubtitlePlaylist - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "MediaSourceId", Description = "MediaSourceId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string MediaSourceId { get; set; } - - [ApiMember(Name = "Index", Description = "The subtitle stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")] - public int Index { get; set; } - - [ApiMember(Name = "SegmentLength", Description = "The subtitle srgment length", IsRequired = true, DataType = "int", ParameterType = "query", Verb = "GET")] - public int SegmentLength { get; set; } - } - - public class SubtitleService : BaseApiService - { - private readonly ILibraryManager _libraryManager; - private readonly ISubtitleManager _subtitleManager; - private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; - private readonly IAuthorizationContext _authContext; - - public SubtitleService( - ILogger<SubtitleService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ILibraryManager libraryManager, - ISubtitleManager subtitleManager, - ISubtitleEncoder subtitleEncoder, - IMediaSourceManager mediaSourceManager, - IProviderManager providerManager, - IFileSystem fileSystem, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _libraryManager = libraryManager; - _subtitleManager = subtitleManager; - _subtitleEncoder = subtitleEncoder; - _mediaSourceManager = mediaSourceManager; - _providerManager = providerManager; - _fileSystem = fileSystem; - _authContext = authContext; - } - - public async Task<object> Get(GetSubtitlePlaylist request) - { - var item = (Video)_libraryManager.GetItemById(new Guid(request.Id)); - - var mediaSource = await _mediaSourceManager.GetMediaSource(item, request.MediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); - - var builder = new StringBuilder(); - - var runtime = mediaSource.RunTimeTicks ?? -1; - - if (runtime <= 0) - { - throw new ArgumentException("HLS Subtitles are not supported for this media."); - } - - var segmentLengthTicks = TimeSpan.FromSeconds(request.SegmentLength).Ticks; - if (segmentLengthTicks <= 0) - { - throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)"); - } - - builder.AppendLine("#EXTM3U"); - builder.AppendLine("#EXT-X-TARGETDURATION:" + request.SegmentLength.ToString(CultureInfo.InvariantCulture)); - builder.AppendLine("#EXT-X-VERSION:3"); - builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); - builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); - - long positionTicks = 0; - - var accessToken = _authContext.GetAuthorizationInfo(Request).Token; - - while (positionTicks < runtime) - { - var remaining = runtime - positionTicks; - var lengthTicks = Math.Min(remaining, segmentLengthTicks); - - builder.AppendLine("#EXTINF:" + TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture) + ","); - - var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); - - var url = string.Format("stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", - positionTicks.ToString(CultureInfo.InvariantCulture), - endPositionTicks.ToString(CultureInfo.InvariantCulture), - accessToken); - - builder.AppendLine(url); - - positionTicks += segmentLengthTicks; - } - - builder.AppendLine("#EXT-X-ENDLIST"); - - return ResultFactory.GetResult(Request, builder.ToString(), MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>()); - } - - public async Task<object> Get(GetSubtitle request) - { - if (string.Equals(request.Format, "js", StringComparison.OrdinalIgnoreCase)) - { - request.Format = "json"; - } - - if (string.IsNullOrEmpty(request.Format)) - { - var item = (Video)_libraryManager.GetItemById(request.Id); - - var idString = request.Id.ToString("N", CultureInfo.InvariantCulture); - var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false, null) - .First(i => string.Equals(i.Id, request.MediaSourceId ?? idString)); - - var subtitleStream = mediaSource.MediaStreams - .First(i => i.Type == MediaStreamType.Subtitle && i.Index == request.Index); - - return await ResultFactory.GetStaticFileResult(Request, subtitleStream.Path).ConfigureAwait(false); - } - - if (string.Equals(request.Format, "vtt", StringComparison.OrdinalIgnoreCase) && request.AddVttTimeMap) - { - using var stream = await GetSubtitles(request).ConfigureAwait(false); - using var reader = new StreamReader(stream); - - var text = reader.ReadToEnd(); - - text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000"); - - return ResultFactory.GetResult(Request, text, MimeTypes.GetMimeType("file." + request.Format)); - } - - return ResultFactory.GetResult(Request, await GetSubtitles(request).ConfigureAwait(false), MimeTypes.GetMimeType("file." + request.Format)); - } - - private Task<Stream> GetSubtitles(GetSubtitle request) - { - var item = _libraryManager.GetItemById(request.Id); - - return _subtitleEncoder.GetSubtitles(item, - request.MediaSourceId, - request.Index, - request.Format, - request.StartPositionTicks, - request.EndPositionTicks ?? 0, - request.CopyTimestamps, - CancellationToken.None); - } - - public async Task<object> Get(SearchRemoteSubtitles request) - { - var video = (Video)_libraryManager.GetItemById(request.Id); - - return await _subtitleManager.SearchSubtitles(video, request.Language, request.IsPerfectMatch, CancellationToken.None).ConfigureAwait(false); - } - - public Task Delete(DeleteSubtitle request) - { - var item = _libraryManager.GetItemById(request.Id); - return _subtitleManager.DeleteSubtitles(item, request.Index); - } - - public async Task<object> Get(GetRemoteSubtitles request) - { - var result = await _subtitleManager.GetRemoteSubtitles(request.Id, CancellationToken.None).ConfigureAwait(false); - - return ResultFactory.GetResult(Request, result.Stream, MimeTypes.GetMimeType("file." + result.Format)); - } - - public void Post(DownloadRemoteSubtitles request) - { - var video = (Video)_libraryManager.GetItemById(request.Id); - - Task.Run(async () => - { - try - { - await _subtitleManager.DownloadSubtitles(video, request.SubtitleId, CancellationToken.None) - .ConfigureAwait(false); - - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error downloading subtitles"); - } - }); - } - } -} diff --git a/MediaBrowser.Api/SuggestionsService.cs b/MediaBrowser.Api/SuggestionsService.cs deleted file mode 100644 index 17afa8e79..000000000 --- a/MediaBrowser.Api/SuggestionsService.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.Linq; -using Jellyfin.Data.Entities; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Users/{UserId}/Suggestions", "GET", Summary = "Gets items based on a query.")] - public class GetSuggestedItems : IReturn<QueryResult<BaseItemDto>> - { - public string MediaType { get; set; } - - public string Type { get; set; } - - public Guid UserId { get; set; } - - public bool EnableTotalRecordCount { get; set; } - - public int? StartIndex { get; set; } - - public int? Limit { get; set; } - - public string[] GetMediaTypes() - { - return (MediaType ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetIncludeItemTypes() - { - return (Type ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - } - - public class SuggestionsService : BaseApiService - { - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - - public SuggestionsService( - ILogger<SuggestionsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IDtoService dtoService, - IAuthorizationContext authContext, - IUserManager userManager, - ILibraryManager libraryManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _dtoService = dtoService; - _authContext = authContext; - _userManager = userManager; - _libraryManager = libraryManager; - } - - public object Get(GetSuggestedItems request) - { - return GetResultItems(request); - } - - private QueryResult<BaseItemDto> GetResultItems(GetSuggestedItems request) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var dtoOptions = GetDtoOptions(_authContext, request); - var result = GetItems(request, user, dtoOptions); - - var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); - - return new QueryResult<BaseItemDto> - { - TotalRecordCount = result.TotalRecordCount, - Items = dtoList - }; - } - - private QueryResult<BaseItem> GetItems(GetSuggestedItems request, User user, DtoOptions dtoOptions) - { - return _libraryManager.GetItemsResult(new InternalItemsQuery(user) - { - OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), - MediaTypes = request.GetMediaTypes(), - IncludeItemTypes = request.GetIncludeItemTypes(), - IsVirtualItem = false, - StartIndex = request.StartIndex, - Limit = request.Limit, - DtoOptions = dtoOptions, - EnableTotalRecordCount = request.EnableTotalRecordCount, - Recursive = true - }); - } - } -} diff --git a/MediaBrowser.Api/SyncPlay/SyncPlayService.cs b/MediaBrowser.Api/SyncPlay/SyncPlayService.cs deleted file mode 100644 index daa1b521f..000000000 --- a/MediaBrowser.Api/SyncPlay/SyncPlayService.cs +++ /dev/null @@ -1,262 +0,0 @@ -using System.Threading; -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Controller.SyncPlay; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.SyncPlay; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.SyncPlay -{ - [Route("/SyncPlay/New", "POST", Summary = "Create a new SyncPlay group")] - [Authenticated] - public class SyncPlayNew : IReturnVoid - { - } - - [Route("/SyncPlay/Join", "POST", Summary = "Join an existing SyncPlay group")] - [Authenticated] - public class SyncPlayJoin : IReturnVoid - { - /// <summary> - /// Gets or sets the Group id. - /// </summary> - /// <value>The Group id to join.</value> - [ApiMember(Name = "GroupId", Description = "Group Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string GroupId { get; set; } - } - - [Route("/SyncPlay/Leave", "POST", Summary = "Leave joined SyncPlay group")] - [Authenticated] - public class SyncPlayLeave : IReturnVoid - { - } - - [Route("/SyncPlay/List", "GET", Summary = "List SyncPlay groups")] - [Authenticated] - public class SyncPlayList : IReturnVoid - { - /// <summary> - /// Gets or sets the filter item id. - /// </summary> - /// <value>The filter item id.</value> - [ApiMember(Name = "FilterItemId", Description = "Filter by item id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string FilterItemId { get; set; } - } - - [Route("/SyncPlay/Play", "POST", Summary = "Request play in SyncPlay group")] - [Authenticated] - public class SyncPlayPlay : IReturnVoid - { - } - - [Route("/SyncPlay/Pause", "POST", Summary = "Request pause in SyncPlay group")] - [Authenticated] - public class SyncPlayPause : IReturnVoid - { - } - - [Route("/SyncPlay/Seek", "POST", Summary = "Request seek in SyncPlay group")] - [Authenticated] - public class SyncPlaySeek : IReturnVoid - { - [ApiMember(Name = "PositionTicks", IsRequired = true, DataType = "long", ParameterType = "query", Verb = "POST")] - public long PositionTicks { get; set; } - } - - [Route("/SyncPlay/Buffering", "POST", Summary = "Request group wait in SyncPlay group while buffering")] - [Authenticated] - public class SyncPlayBuffering : IReturnVoid - { - /// <summary> - /// Gets or sets the date used to pin PositionTicks in time. - /// </summary> - /// <value>The date related to PositionTicks.</value> - [ApiMember(Name = "When", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string When { get; set; } - - [ApiMember(Name = "PositionTicks", IsRequired = true, DataType = "long", ParameterType = "query", Verb = "POST")] - public long PositionTicks { get; set; } - - /// <summary> - /// Gets or sets whether this is a buffering or a ready request. - /// </summary> - /// <value><c>true</c> if buffering is complete; <c>false</c> otherwise.</value> - [ApiMember(Name = "BufferingDone", IsRequired = true, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool BufferingDone { get; set; } - } - - [Route("/SyncPlay/Ping", "POST", Summary = "Update session ping")] - [Authenticated] - public class SyncPlayPing : IReturnVoid - { - [ApiMember(Name = "Ping", IsRequired = true, DataType = "double", ParameterType = "query", Verb = "POST")] - public double Ping { get; set; } - } - - /// <summary> - /// Class SyncPlayService. - /// </summary> - public class SyncPlayService : BaseApiService - { - /// <summary> - /// The session context. - /// </summary> - private readonly ISessionContext _sessionContext; - - /// <summary> - /// The SyncPlay manager. - /// </summary> - private readonly ISyncPlayManager _syncPlayManager; - - public SyncPlayService( - ILogger<SyncPlayService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ISessionContext sessionContext, - ISyncPlayManager syncPlayManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _sessionContext = sessionContext; - _syncPlayManager = syncPlayManager; - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(SyncPlayNew request) - { - var currentSession = GetSession(_sessionContext); - _syncPlayManager.NewGroup(currentSession, CancellationToken.None); - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(SyncPlayJoin request) - { - var currentSession = GetSession(_sessionContext); - - Guid groupId; - if (!Guid.TryParse(request.GroupId, out groupId)) - { - Logger.LogError("JoinGroup: {0} is not a valid format for GroupId. Ignoring request.", request.GroupId); - return; - } - - var joinRequest = new JoinGroupRequest() - { - GroupId = groupId - }; - - _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None); - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(SyncPlayLeave request) - { - var currentSession = GetSession(_sessionContext); - _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None); - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <value>The requested list of groups.</value> - public List<GroupInfoView> Get(SyncPlayList request) - { - var currentSession = GetSession(_sessionContext); - var filterItemId = Guid.Empty; - - if (!string.IsNullOrEmpty(request.FilterItemId) && !Guid.TryParse(request.FilterItemId, out filterItemId)) - { - Logger.LogWarning("ListGroups: {0} is not a valid format for FilterItemId. Ignoring filter.", request.FilterItemId); - } - - return _syncPlayManager.ListGroups(currentSession, filterItemId); - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(SyncPlayPlay request) - { - var currentSession = GetSession(_sessionContext); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Play - }; - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(SyncPlayPause request) - { - var currentSession = GetSession(_sessionContext); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Pause - }; - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(SyncPlaySeek request) - { - var currentSession = GetSession(_sessionContext); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Seek, - PositionTicks = request.PositionTicks - }; - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(SyncPlayBuffering request) - { - var currentSession = GetSession(_sessionContext); - var syncPlayRequest = new PlaybackRequest() - { - Type = request.BufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer, - When = DateTime.Parse(request.When), - PositionTicks = request.PositionTicks - }; - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(SyncPlayPing request) - { - var currentSession = GetSession(_sessionContext); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Ping, - Ping = Convert.ToInt64(request.Ping) - }; - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - } -} diff --git a/MediaBrowser.Api/SyncPlay/TimeSyncService.cs b/MediaBrowser.Api/SyncPlay/TimeSyncService.cs deleted file mode 100644 index 4a9307e62..000000000 --- a/MediaBrowser.Api/SyncPlay/TimeSyncService.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.SyncPlay; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.SyncPlay -{ - [Route("/GetUtcTime", "GET", Summary = "Get UtcTime")] - public class GetUtcTime : IReturnVoid - { - // Nothing - } - - /// <summary> - /// Class TimeSyncService. - /// </summary> - public class TimeSyncService : BaseApiService - { - public TimeSyncService( - ILogger<TimeSyncService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory) - : base(logger, serverConfigurationManager, httpResultFactory) - { - // Do nothing - } - - /// <summary> - /// Handles the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <value>The current UTC time response.</value> - public UtcTimeResponse Get(GetUtcTime request) - { - // Important to keep the following line at the beginning - var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o"); - - var response = new UtcTimeResponse(); - response.RequestReceptionTime = requestReceptionTime; - - // Important to keep the following two lines at the end - var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o"); - response.ResponseTransmissionTime = responseTransmissionTime; - - // Implementing NTP on such a high level results in this useless - // information being sent. On the other hand it enables future additions. - return response; - } - } -} diff --git a/MediaBrowser.Api/System/ActivityLogService.cs b/MediaBrowser.Api/System/ActivityLogService.cs deleted file mode 100644 index 4afa6e114..000000000 --- a/MediaBrowser.Api/System/ActivityLogService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using Jellyfin.Data.Entities; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.System -{ - [Route("/System/ActivityLog/Entries", "GET", Summary = "Gets activity log entries")] - public class GetActivityLogs : IReturn<QueryResult<ActivityLogEntry>> - { - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "MinDate", Description = "Optional. The minimum date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string MinDate { get; set; } - - public bool? HasUserId { get; set; } - } - - [Authenticated(Roles = "Admin")] - public class ActivityLogService : BaseApiService - { - private readonly IActivityManager _activityManager; - - public ActivityLogService( - ILogger<ActivityLogService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IActivityManager activityManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _activityManager = activityManager; - } - - public object Get(GetActivityLogs request) - { - DateTime? minDate = string.IsNullOrWhiteSpace(request.MinDate) ? - (DateTime?)null : - DateTime.Parse(request.MinDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - - var filterFunc = new Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>>( - entries => entries.Where(entry => entry.DateCreated >= minDate - && (!request.HasUserId.HasValue || (request.HasUserId.Value - ? entry.UserId != Guid.Empty - : entry.UserId == Guid.Empty)))); - - var result = _activityManager.GetPagedResult(filterFunc, request.StartIndex, request.Limit); - - return ToOptimizedResult(result); - } - } -} diff --git a/MediaBrowser.Api/System/SystemService.cs b/MediaBrowser.Api/System/SystemService.cs deleted file mode 100644 index e0e20d828..000000000 --- a/MediaBrowser.Api/System/SystemService.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.System; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.System -{ - /// <summary> - /// Class GetSystemInfo. - /// </summary> - [Route("/System/Info", "GET", Summary = "Gets information about the server")] - [Authenticated(EscapeParentalControl = true, AllowBeforeStartupWizard = true)] - public class GetSystemInfo : IReturn<SystemInfo> - { - } - - [Route("/System/Info/Public", "GET", Summary = "Gets public information about the server")] - public class GetPublicSystemInfo : IReturn<PublicSystemInfo> - { - } - - [Route("/System/Ping", "POST")] - [Route("/System/Ping", "GET")] - public class PingSystem : IReturnVoid - { - } - - /// <summary> - /// Class RestartApplication. - /// </summary> - [Route("/System/Restart", "POST", Summary = "Restarts the application, if needed")] - [Authenticated(Roles = "Admin", AllowLocal = true)] - public class RestartApplication - { - } - - /// <summary> - /// This is currently not authenticated because the uninstaller needs to be able to shutdown the server. - /// </summary> - [Route("/System/Shutdown", "POST", Summary = "Shuts down the application")] - [Authenticated(Roles = "Admin", AllowLocal = true)] - public class ShutdownApplication - { - } - - [Route("/System/Logs", "GET", Summary = "Gets a list of available server log files")] - [Authenticated(Roles = "Admin")] - public class GetServerLogs : IReturn<LogFile[]> - { - } - - [Route("/System/Endpoint", "GET", Summary = "Gets information about the request endpoint")] - [Authenticated] - public class GetEndpointInfo : IReturn<EndPointInfo> - { - public string Endpoint { get; set; } - } - - [Route("/System/Logs/Log", "GET", Summary = "Gets a log file")] - [Authenticated(Roles = "Admin")] - public class GetLogFile - { - [ApiMember(Name = "Name", Description = "The log file name.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Name { get; set; } - } - - [Route("/System/WakeOnLanInfo", "GET", Summary = "Gets wake on lan information")] - [Authenticated] - public class GetWakeOnLanInfo : IReturn<WakeOnLanInfo[]> - { - } - - /// <summary> - /// Class SystemInfoService. - /// </summary> - public class SystemService : BaseApiService - { - /// <summary> - /// The _app host. - /// </summary> - private readonly IServerApplicationHost _appHost; - private readonly IApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - - private readonly INetworkManager _network; - - /// <summary> - /// Initializes a new instance of the <see cref="SystemService" /> class. - /// </summary> - /// <param name="appHost">The app host.</param> - /// <param name="fileSystem">The file system.</param> - /// <exception cref="ArgumentNullException">jsonSerializer</exception> - public SystemService( - ILogger<SystemService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IServerApplicationHost appHost, - IFileSystem fileSystem, - INetworkManager network) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _appPaths = serverConfigurationManager.ApplicationPaths; - _appHost = appHost; - _fileSystem = fileSystem; - _network = network; - } - - public object Post(PingSystem request) - { - return _appHost.Name; - } - - public object Get(GetWakeOnLanInfo request) - { - var result = _appHost.GetWakeOnLanInfo(); - - return ToOptimizedResult(result); - } - - public object Get(GetServerLogs request) - { - IEnumerable<FileSystemMetadata> files; - - try - { - files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false); - } - catch (IOException ex) - { - Logger.LogError(ex, "Error getting logs"); - files = Enumerable.Empty<FileSystemMetadata>(); - } - - var result = files.Select(i => new LogFile - { - DateCreated = _fileSystem.GetCreationTimeUtc(i), - DateModified = _fileSystem.GetLastWriteTimeUtc(i), - Name = i.Name, - Size = i.Length - }).OrderByDescending(i => i.DateModified) - .ThenByDescending(i => i.DateCreated) - .ThenBy(i => i.Name) - .ToArray(); - - return ToOptimizedResult(result); - } - - public Task<object> Get(GetLogFile request) - { - var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) - .First(i => string.Equals(i.Name, request.Name, StringComparison.OrdinalIgnoreCase)); - - // For older files, assume fully static - var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; - - return ResultFactory.GetStaticFileResult(Request, file.FullName, fileShare); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Get(GetSystemInfo request) - { - var result = await _appHost.GetSystemInfo(CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Get(GetPublicSystemInfo request) - { - var result = await _appHost.GetPublicSystemInfo(CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(RestartApplication request) - { - _appHost.Restart(); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(ShutdownApplication request) - { - Task.Run(async () => - { - await Task.Delay(100).ConfigureAwait(false); - await _appHost.Shutdown().ConfigureAwait(false); - }); - } - - public object Get(GetEndpointInfo request) - { - return ToOptimizedResult(new EndPointInfo - { - IsLocal = Request.IsLocal, - IsInNetwork = _network.IsInLocalNetwork(request.Endpoint ?? Request.RemoteIp) - }); - } - } -} diff --git a/MediaBrowser.Api/TvShowsService.cs b/MediaBrowser.Api/TvShowsService.cs deleted file mode 100644 index 165abd613..000000000 --- a/MediaBrowser.Api/TvShowsService.cs +++ /dev/null @@ -1,497 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.TV; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class GetNextUpEpisodes. - /// </summary> - [Route("/Shows/NextUp", "GET", Summary = "Gets a list of next up episodes")] - public class GetNextUpEpisodes : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - [ApiMember(Name = "SeriesId", Description = "Optional. Filter by series id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SeriesId { get; set; } - - /// <summary> - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// </summary> - /// <value>The parent id.</value> - [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ParentId { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - public bool EnableTotalRecordCount { get; set; } - - public GetNextUpEpisodes() - { - EnableTotalRecordCount = true; - } - } - - [Route("/Shows/Upcoming", "GET", Summary = "Gets a list of upcoming episodes")] - public class GetUpcomingEpisodes : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - /// <summary> - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// </summary> - /// <value>The parent id.</value> - [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ParentId { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - } - - [Route("/Shows/{Id}/Episodes", "GET", Summary = "Gets episodes for a tv season")] - public class GetEpisodes : IReturn<QueryResult<BaseItemDto>>, IHasItemFields, IHasDtoOptions - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - [ApiMember(Name = "Id", Description = "The series id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "Season", Description = "Optional filter by season number.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public int? Season { get; set; } - - [ApiMember(Name = "SeasonId", Description = "Optional. Filter by season id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SeasonId { get; set; } - - [ApiMember(Name = "IsMissing", Description = "Optional filter by items that are missing episodes or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsMissing { get; set; } - - [ApiMember(Name = "AdjacentTo", Description = "Optional. Return items that are siblings of a supplied item.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string AdjacentTo { get; set; } - - [ApiMember(Name = "StartItemId", Description = "Optional. Skip through the list until a given item is found.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string StartItemId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - [ApiMember(Name = "SortBy", Description = "Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string SortBy { get; set; } - - [ApiMember(Name = "SortOrder", Description = "Sort Order - Ascending,Descending", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public SortOrder? SortOrder { get; set; } - } - - [Route("/Shows/{Id}/Seasons", "GET", Summary = "Gets seasons for a tv series")] - public class GetSeasons : IReturn<QueryResult<BaseItemDto>>, IHasItemFields, IHasDtoOptions - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - [ApiMember(Name = "Id", Description = "The series id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "IsSpecialSeason", Description = "Optional. Filter by special season.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsSpecialSeason { get; set; } - - [ApiMember(Name = "IsMissing", Description = "Optional filter by items that are missing episodes or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsMissing { get; set; } - - [ApiMember(Name = "AdjacentTo", Description = "Optional. Return items that are siblings of a supplied item.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string AdjacentTo { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - } - - /// <summary> - /// Class TvShowsService. - /// </summary> - [Authenticated] - public class TvShowsService : BaseApiService - { - /// <summary> - /// The _user manager. - /// </summary> - private readonly IUserManager _userManager; - - /// <summary> - /// The _library manager. - /// </summary> - private readonly ILibraryManager _libraryManager; - - private readonly IDtoService _dtoService; - private readonly ITVSeriesManager _tvSeriesManager; - private readonly IAuthorizationContext _authContext; - - /// <summary> - /// Initializes a new instance of the <see cref="TvShowsService" /> class. - /// </summary> - /// <param name="userManager">The user manager.</param> - /// <param name="userDataManager">The user data repository.</param> - /// <param name="libraryManager">The library manager.</param> - public TvShowsService( - ILogger<TvShowsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - ITVSeriesManager tvSeriesManager, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - _tvSeriesManager = tvSeriesManager; - _authContext = authContext; - } - - public object Get(GetUpcomingEpisodes request) - { - var user = _userManager.GetUserById(request.UserId); - - var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1); - - var parentIdGuid = string.IsNullOrWhiteSpace(request.ParentId) ? Guid.Empty : new Guid(request.ParentId); - - var options = GetDtoOptions(_authContext, request); - - var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { typeof(Episode).Name }, - OrderBy = new[] { ItemSortBy.PremiereDate, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(), - MinPremiereDate = minPremiereDate, - StartIndex = request.StartIndex, - Limit = request.Limit, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = options - }); - - var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user); - - var result = new QueryResult<BaseItemDto> - { - TotalRecordCount = itemsResult.Count, - Items = returnItems - }; - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetNextUpEpisodes request) - { - var options = GetDtoOptions(_authContext, request); - - var result = _tvSeriesManager.GetNextUp(new NextUpQuery - { - Limit = request.Limit, - ParentId = request.ParentId, - SeriesId = request.SeriesId, - StartIndex = request.StartIndex, - UserId = request.UserId, - EnableTotalRecordCount = request.EnableTotalRecordCount - }, options); - - var user = _userManager.GetUserById(request.UserId); - - var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user); - - return ToOptimizedResult(new QueryResult<BaseItemDto> - { - TotalRecordCount = result.TotalRecordCount, - Items = returnItems - }); - } - - /// <summary> - /// Applies the paging. - /// </summary> - /// <param name="items">The items.</param> - /// <param name="startIndex">The start index.</param> - /// <param name="limit">The limit.</param> - /// <returns>IEnumerable{BaseItem}.</returns> - private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit) - { - // Start at - if (startIndex.HasValue) - { - items = items.Skip(startIndex.Value); - } - - // Return limit - if (limit.HasValue) - { - items = items.Take(limit.Value); - } - - return items; - } - - public object Get(GetSeasons request) - { - var user = _userManager.GetUserById(request.UserId); - - var series = GetSeries(request.Id); - - if (series == null) - { - throw new ResourceNotFoundException("Series not found"); - } - - var seasons = series.GetItemList(new InternalItemsQuery(user) - { - IsMissing = request.IsMissing, - IsSpecialSeason = request.IsSpecialSeason, - AdjacentTo = request.AdjacentTo - }); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); - - return new QueryResult<BaseItemDto> - { - TotalRecordCount = returnItems.Count, - Items = returnItems - }; - } - - private Series GetSeries(string seriesId) - { - if (!string.IsNullOrWhiteSpace(seriesId)) - { - return _libraryManager.GetItemById(seriesId) as Series; - } - - return null; - } - - public object Get(GetEpisodes request) - { - var user = _userManager.GetUserById(request.UserId); - - List<BaseItem> episodes; - - var dtoOptions = GetDtoOptions(_authContext, request); - - if (!string.IsNullOrWhiteSpace(request.SeasonId)) - { - if (!(_libraryManager.GetItemById(new Guid(request.SeasonId)) is Season season)) - { - throw new ResourceNotFoundException("No season exists with Id " + request.SeasonId); - } - - episodes = season.GetEpisodes(user, dtoOptions); - } - else if (request.Season.HasValue) - { - var series = GetSeries(request.Id); - - if (series == null) - { - throw new ResourceNotFoundException("Series not found"); - } - - var season = series.GetSeasons(user, dtoOptions).FirstOrDefault(i => i.IndexNumber == request.Season.Value); - - episodes = season == null ? new List<BaseItem>() : ((Season)season).GetEpisodes(user, dtoOptions); - } - else - { - var series = GetSeries(request.Id); - - if (series == null) - { - throw new ResourceNotFoundException("Series not found"); - } - - episodes = series.GetEpisodes(user, dtoOptions).ToList(); - } - - // Filter after the fact in case the ui doesn't want them - if (request.IsMissing.HasValue) - { - var val = request.IsMissing.Value; - episodes = episodes.Where(i => ((Episode)i).IsMissingEpisode == val).ToList(); - } - - if (!string.IsNullOrWhiteSpace(request.StartItemId)) - { - episodes = episodes.SkipWhile(i => !string.Equals(i.Id.ToString("N", CultureInfo.InvariantCulture), request.StartItemId, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - // This must be the last filter - if (!string.IsNullOrEmpty(request.AdjacentTo)) - { - episodes = UserViewBuilder.FilterForAdjacency(episodes, request.AdjacentTo).ToList(); - } - - if (string.Equals(request.SortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) - { - episodes.Shuffle(); - } - - var returnItems = episodes; - - if (request.StartIndex.HasValue || request.Limit.HasValue) - { - returnItems = ApplyPaging(episodes, request.StartIndex, request.Limit).ToList(); - } - - var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); - - return new QueryResult<BaseItemDto> - { - TotalRecordCount = episodes.Count, - Items = dtos - }; - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/ArtistsService.cs b/MediaBrowser.Api/UserLibrary/ArtistsService.cs deleted file mode 100644 index 9875e0208..000000000 --- a/MediaBrowser.Api/UserLibrary/ArtistsService.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - /// <summary> - /// Class GetArtists. - /// </summary> - [Route("/Artists", "GET", Summary = "Gets all artists from a given item, folder, or the entire library")] - public class GetArtists : GetItemsByName - { - } - - [Route("/Artists/AlbumArtists", "GET", Summary = "Gets all album artists from a given item, folder, or the entire library")] - public class GetAlbumArtists : GetItemsByName - { - } - - [Route("/Artists/{Name}", "GET", Summary = "Gets an artist, by name")] - public class GetArtist : IReturn<BaseItemDto> - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "The artist name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - /// <summary> - /// Class ArtistsService. - /// </summary> - [Authenticated] - public class ArtistsService : BaseItemsByNameService<MusicArtist> - { - public ArtistsService( - ILogger<ArtistsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IUserDataManager userDataRepository, - IDtoService dtoService, - IAuthorizationContext authorizationContext) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - userDataRepository, - dtoService, - authorizationContext) - { - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetArtist request) - { - return GetItem(request); - } - - /// <summary> - /// Gets the item. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{BaseItemDto}.</returns> - private BaseItemDto GetItem(GetArtist request) - { - var dtoOptions = GetDtoOptions(AuthorizationContext, request); - - var item = GetArtist(request.Name, LibraryManager, dtoOptions); - - if (!request.UserId.Equals(Guid.Empty)) - { - var user = UserManager.GetUserById(request.UserId); - - return DtoService.GetBaseItemDto(item, dtoOptions, user); - } - - return DtoService.GetBaseItemDto(item, dtoOptions); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetArtists request) - { - return GetResultSlim(request); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetAlbumArtists request) - { - var result = GetResultSlim(request); - - return ToOptimizedResult(result); - } - - protected override QueryResult<(BaseItem, ItemCounts)> GetItems(GetItemsByName request, InternalItemsQuery query) - { - return request is GetAlbumArtists ? LibraryManager.GetAlbumArtists(query) : LibraryManager.GetArtists(query); - } - - /// <summary> - /// Gets all items. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="items">The items.</param> - /// <returns>IEnumerable{Tuple{System.StringFunc{System.Int32}}}.</returns> - protected override IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items) - { - throw new NotImplementedException(); - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/BaseItemsByNameService.cs b/MediaBrowser.Api/UserLibrary/BaseItemsByNameService.cs deleted file mode 100644 index fd639caf1..000000000 --- a/MediaBrowser.Api/UserLibrary/BaseItemsByNameService.cs +++ /dev/null @@ -1,388 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Data.Entities; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - /// <summary> - /// Class BaseItemsByNameService. - /// </summary> - /// <typeparam name="TItemType">The type of the T item type.</typeparam> - public abstract class BaseItemsByNameService<TItemType> : BaseApiService - where TItemType : BaseItem, IItemByName - { - /// <summary> - /// Initializes a new instance of the <see cref="BaseItemsByNameService{TItemType}" /> class. - /// </summary> - /// <param name="userManager">The user manager.</param> - /// <param name="libraryManager">The library manager.</param> - /// <param name="userDataRepository">The user data repository.</param> - /// <param name="dtoService">The dto service.</param> - protected BaseItemsByNameService( - ILogger<BaseItemsByNameService<TItemType>> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IUserDataManager userDataRepository, - IDtoService dtoService, - IAuthorizationContext authorizationContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - UserManager = userManager; - LibraryManager = libraryManager; - UserDataRepository = userDataRepository; - DtoService = dtoService; - AuthorizationContext = authorizationContext; - } - - /// <summary> - /// Gets the _user manager. - /// </summary> - protected IUserManager UserManager { get; } - - /// <summary> - /// Gets the library manager. - /// </summary> - protected ILibraryManager LibraryManager { get; } - - protected IUserDataManager UserDataRepository { get; } - - protected IDtoService DtoService { get; } - - protected IAuthorizationContext AuthorizationContext { get; } - - protected BaseItem GetParentItem(GetItemsByName request) - { - BaseItem parentItem; - - if (!request.UserId.Equals(Guid.Empty)) - { - var user = UserManager.GetUserById(request.UserId); - parentItem = string.IsNullOrEmpty(request.ParentId) ? LibraryManager.GetUserRootFolder() : LibraryManager.GetItemById(request.ParentId); - } - else - { - parentItem = string.IsNullOrEmpty(request.ParentId) ? LibraryManager.RootFolder : LibraryManager.GetItemById(request.ParentId); - } - - return parentItem; - } - - protected string GetParentItemViewType(GetItemsByName request) - { - var parent = GetParentItem(request); - - if (parent is IHasCollectionType collectionFolder) - { - return collectionFolder.CollectionType; - } - - return null; - } - - protected QueryResult<BaseItemDto> GetResultSlim(GetItemsByName request) - { - var dtoOptions = GetDtoOptions(AuthorizationContext, request); - - User user = null; - BaseItem parentItem; - - if (!request.UserId.Equals(Guid.Empty)) - { - user = UserManager.GetUserById(request.UserId); - parentItem = string.IsNullOrEmpty(request.ParentId) ? LibraryManager.GetUserRootFolder() : LibraryManager.GetItemById(request.ParentId); - } - else - { - parentItem = string.IsNullOrEmpty(request.ParentId) ? LibraryManager.RootFolder : LibraryManager.GetItemById(request.ParentId); - } - - var excludeItemTypes = request.GetExcludeItemTypes(); - var includeItemTypes = request.GetIncludeItemTypes(); - var mediaTypes = request.GetMediaTypes(); - - var query = new InternalItemsQuery(user) - { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - MediaTypes = mediaTypes, - StartIndex = request.StartIndex, - Limit = request.Limit, - IsFavorite = request.IsFavorite, - NameLessThan = request.NameLessThan, - NameStartsWith = request.NameStartsWith, - NameStartsWithOrGreater = request.NameStartsWithOrGreater, - Tags = request.GetTags(), - OfficialRatings = request.GetOfficialRatings(), - Genres = request.GetGenres(), - GenreIds = GetGuids(request.GenreIds), - StudioIds = GetGuids(request.StudioIds), - Person = request.Person, - PersonIds = GetGuids(request.PersonIds), - PersonTypes = request.GetPersonTypes(), - Years = request.GetYears(), - MinCommunityRating = request.MinCommunityRating, - DtoOptions = dtoOptions, - SearchTerm = request.SearchTerm, - EnableTotalRecordCount = request.EnableTotalRecordCount - }; - - if (!string.IsNullOrWhiteSpace(request.ParentId)) - { - if (parentItem is Folder) - { - query.AncestorIds = new[] { new Guid(request.ParentId) }; - } - else - { - query.ItemIds = new[] { new Guid(request.ParentId) }; - } - } - - // Studios - if (!string.IsNullOrEmpty(request.Studios)) - { - query.StudioIds = request.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 request.GetFilters()) - { - 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 = GetItems(request, query); - - var dtos = result.Items.Select(i => - { - var dto = DtoService.GetItemByNameDto(i.Item1, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(request.IncludeItemTypes)) - { - SetItemCounts(dto, i.Item2); - } - - return dto; - }); - - return new QueryResult<BaseItemDto> - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; - } - - protected virtual QueryResult<(BaseItem, ItemCounts)> GetItems(GetItemsByName request, InternalItemsQuery query) - { - return new QueryResult<(BaseItem, ItemCounts)>(); - } - - private void SetItemCounts(BaseItemDto dto, ItemCounts counts) - { - 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; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{ItemsResult}.</returns> - protected QueryResult<BaseItemDto> GetResult(GetItemsByName request) - { - var dtoOptions = GetDtoOptions(AuthorizationContext, request); - - User user = null; - BaseItem parentItem; - - if (!request.UserId.Equals(Guid.Empty)) - { - user = UserManager.GetUserById(request.UserId); - parentItem = string.IsNullOrEmpty(request.ParentId) ? LibraryManager.GetUserRootFolder() : LibraryManager.GetItemById(request.ParentId); - } - else - { - parentItem = string.IsNullOrEmpty(request.ParentId) ? LibraryManager.RootFolder : LibraryManager.GetItemById(request.ParentId); - } - - IList<BaseItem> items; - - var excludeItemTypes = request.GetExcludeItemTypes(); - var includeItemTypes = request.GetIncludeItemTypes(); - var mediaTypes = request.GetMediaTypes(); - - var query = new InternalItemsQuery(user) - { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - MediaTypes = mediaTypes, - DtoOptions = dtoOptions - }; - - bool Filter(BaseItem i) => FilterItem(request, i, excludeItemTypes, includeItemTypes, mediaTypes); - - if (parentItem.IsFolder) - { - var folder = (Folder)parentItem; - - if (!request.UserId.Equals(Guid.Empty)) - { - items = request.Recursive ? - folder.GetRecursiveChildren(user, query).ToList() : - folder.GetChildren(user, true).Where(Filter).ToList(); - } - else - { - items = request.Recursive ? - folder.GetRecursiveChildren(Filter) : - folder.Children.Where(Filter).ToList(); - } - } - else - { - items = new[] { parentItem }.Where(Filter).ToList(); - } - - var extractedItems = GetAllItems(request, items); - - var filteredItems = LibraryManager.Sort(extractedItems, user, request.GetOrderBy()); - - var ibnItemsArray = filteredItems.ToList(); - - IEnumerable<BaseItem> ibnItems = ibnItemsArray; - - var result = new QueryResult<BaseItemDto> - { - TotalRecordCount = ibnItemsArray.Count - }; - - if (request.StartIndex.HasValue || request.Limit.HasValue) - { - if (request.StartIndex.HasValue) - { - ibnItems = ibnItems.Skip(request.StartIndex.Value); - } - - if (request.Limit.HasValue) - { - ibnItems = ibnItems.Take(request.Limit.Value); - } - } - - var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>())); - - var dtos = tuples.Select(i => DtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user)); - - result.Items = dtos.Where(i => i != null).ToArray(); - - return result; - } - - /// <summary> - /// Filters the items. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="f">The f.</param> - /// <param name="excludeItemTypes">The exclude item types.</param> - /// <param name="includeItemTypes">The include item types.</param> - /// <param name="mediaTypes">The media types.</param> - /// <returns>IEnumerable{BaseItem}.</returns> - private bool FilterItem(GetItemsByName request, BaseItem f, string[] excludeItemTypes, string[] includeItemTypes, string[] mediaTypes) - { - // Exclude item types - if (excludeItemTypes.Length > 0 && excludeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase)) - { - return false; - } - - // Include item types - if (includeItemTypes.Length > 0 && !includeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase)) - { - return false; - } - - // Include MediaTypes - if (mediaTypes.Length > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) - { - return false; - } - - return true; - } - - /// <summary> - /// Gets all items. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="items">The items.</param> - /// <returns>IEnumerable{Task{`0}}.</returns> - protected abstract IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items); - } - - /// <summary> - /// Class GetItemsByName. - /// </summary> - public class GetItemsByName : BaseItemsRequest, IReturn<QueryResult<BaseItemDto>> - { - public GetItemsByName() - { - Recursive = true; - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/BaseItemsRequest.cs b/MediaBrowser.Api/UserLibrary/BaseItemsRequest.cs deleted file mode 100644 index 344861a49..000000000 --- a/MediaBrowser.Api/UserLibrary/BaseItemsRequest.cs +++ /dev/null @@ -1,478 +0,0 @@ -using System; -using System.Linq; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; - -namespace MediaBrowser.Api.UserLibrary -{ - public abstract class BaseItemsRequest : IHasDtoOptions - { - protected BaseItemsRequest() - { - EnableImages = true; - EnableTotalRecordCount = true; - } - - /// <summary> - /// Gets or sets the max offical rating. - /// </summary> - /// <value>The max offical rating.</value> - [ApiMember(Name = "MaxOfficialRating", Description = "Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string MaxOfficialRating { get; set; } - - [ApiMember(Name = "HasThemeSong", Description = "Optional filter by items with theme songs.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool? HasThemeSong { get; set; } - - [ApiMember(Name = "HasThemeVideo", Description = "Optional filter by items with theme videos.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool? HasThemeVideo { get; set; } - - [ApiMember(Name = "HasSubtitles", Description = "Optional filter by items with subtitles.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool? HasSubtitles { get; set; } - - [ApiMember(Name = "HasSpecialFeature", Description = "Optional filter by items with special features.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool? HasSpecialFeature { get; set; } - - [ApiMember(Name = "HasTrailer", Description = "Optional filter by items with trailers.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool? HasTrailer { get; set; } - - [ApiMember(Name = "AdjacentTo", Description = "Optional. Return items that are siblings of a supplied item.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string AdjacentTo { get; set; } - - [ApiMember(Name = "MinIndexNumber", Description = "Optional filter by minimum index number.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? MinIndexNumber { get; set; } - - [ApiMember(Name = "ParentIndexNumber", Description = "Optional filter by parent index number.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ParentIndexNumber { get; set; } - - [ApiMember(Name = "HasParentalRating", Description = "Optional filter by items that have or do not have a parental rating", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? HasParentalRating { get; set; } - - [ApiMember(Name = "IsHD", Description = "Optional filter by items that are HD or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsHD { get; set; } - - public bool? Is4K { get; set; } - - [ApiMember(Name = "LocationTypes", Description = "Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string LocationTypes { get; set; } - - [ApiMember(Name = "ExcludeLocationTypes", Description = "Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string ExcludeLocationTypes { get; set; } - - [ApiMember(Name = "IsMissing", Description = "Optional filter by items that are missing episodes or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsMissing { get; set; } - - [ApiMember(Name = "IsUnaired", Description = "Optional filter by items that are unaired episodes or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsUnaired { get; set; } - - [ApiMember(Name = "MinCommunityRating", Description = "Optional filter by minimum community rating.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public double? MinCommunityRating { get; set; } - - [ApiMember(Name = "MinCriticRating", Description = "Optional filter by minimum critic rating.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public double? MinCriticRating { get; set; } - - [ApiMember(Name = "AiredDuringSeason", Description = "Gets all episodes that aired during a season, including specials.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? AiredDuringSeason { get; set; } - - [ApiMember(Name = "MinPremiereDate", Description = "Optional. The minimum premiere date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string MinPremiereDate { get; set; } - - [ApiMember(Name = "MinDateLastSaved", Description = "Optional. The minimum premiere date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string MinDateLastSaved { get; set; } - - [ApiMember(Name = "MinDateLastSavedForUser", Description = "Optional. The minimum premiere date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string MinDateLastSavedForUser { get; set; } - - [ApiMember(Name = "MaxPremiereDate", Description = "Optional. The maximum premiere date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string MaxPremiereDate { get; set; } - - [ApiMember(Name = "HasOverview", Description = "Optional filter by items that have an overview or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? HasOverview { get; set; } - - [ApiMember(Name = "HasImdbId", Description = "Optional filter by items that have an imdb id or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? HasImdbId { get; set; } - - [ApiMember(Name = "HasTmdbId", Description = "Optional filter by items that have a tmdb id or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? HasTmdbId { get; set; } - - [ApiMember(Name = "HasTvdbId", Description = "Optional filter by items that have a tvdb id or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? HasTvdbId { get; set; } - - [ApiMember(Name = "ExcludeItemIds", Description = "Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string ExcludeItemIds { get; set; } - - public bool EnableTotalRecordCount { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - /// <summary> - /// Whether or not to perform the query recursively. - /// </summary> - /// <value><c>true</c> if recursive; otherwise, <c>false</c>.</value> - [ApiMember(Name = "Recursive", Description = "When searching within folders, this determines whether or not the search will be recursive. true/false", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool Recursive { get; set; } - - public string SearchTerm { get; set; } - - /// <summary> - /// Gets or sets the sort order. - /// </summary> - /// <value>The sort order.</value> - [ApiMember(Name = "SortOrder", Description = "Sort Order - Ascending,Descending", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SortOrder { get; set; } - - /// <summary> - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// </summary> - /// <value>The parent id.</value> - [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ParentId { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - /// <summary> - /// Gets or sets the exclude item types. - /// </summary> - /// <value>The exclude item types.</value> - [ApiMember(Name = "ExcludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string ExcludeItemTypes { get; set; } - - /// <summary> - /// Gets or sets the include item types. - /// </summary> - /// <value>The include item types.</value> - [ApiMember(Name = "IncludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string IncludeItemTypes { get; set; } - - /// <summary> - /// Filters to apply to the results. - /// </summary> - /// <value>The filters.</value> - [ApiMember(Name = "Filters", Description = "Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Filters { get; set; } - - /// <summary> - /// Gets or sets the Isfavorite option. - /// </summary> - /// <value>IsFavorite</value> - [ApiMember(Name = "IsFavorite", Description = "Optional filter by items that are marked as favorite, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsFavorite { get; set; } - - /// <summary> - /// Gets or sets the media types. - /// </summary> - /// <value>The media types.</value> - [ApiMember(Name = "MediaTypes", Description = "Optional filter by MediaType. Allows multiple, comma delimited.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string MediaTypes { get; set; } - - /// <summary> - /// Gets or sets the image types. - /// </summary> - /// <value>The image types.</value> - [ApiMember(Name = "ImageTypes", Description = "Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string ImageTypes { get; set; } - - /// <summary> - /// What to sort the results by. - /// </summary> - /// <value>The sort by.</value> - [ApiMember(Name = "SortBy", Description = "Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string SortBy { get; set; } - - [ApiMember(Name = "IsPlayed", Description = "Optional filter by items that are played, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsPlayed { get; set; } - - /// <summary> - /// Limit results to items containing specific genres. - /// </summary> - /// <value>The genres.</value> - [ApiMember(Name = "Genres", Description = "Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Genres { get; set; } - - public string GenreIds { get; set; } - - [ApiMember(Name = "OfficialRatings", Description = "Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string OfficialRatings { get; set; } - - [ApiMember(Name = "Tags", Description = "Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Tags { get; set; } - - /// <summary> - /// Limit results to items containing specific years. - /// </summary> - /// <value>The years.</value> - [ApiMember(Name = "Years", Description = "Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Years { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - /// <summary> - /// Limit results to items containing a specific person. - /// </summary> - /// <value>The person.</value> - [ApiMember(Name = "Person", Description = "Optional. If specified, results will be filtered to include only those containing the specified person.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Person { get; set; } - - [ApiMember(Name = "PersonIds", Description = "Optional. If specified, results will be filtered to include only those containing the specified person.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string PersonIds { get; set; } - - /// <summary> - /// If the Person filter is used, this can also be used to restrict to a specific person type. - /// </summary> - /// <value>The type of the person.</value> - [ApiMember(Name = "PersonTypes", Description = "Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string PersonTypes { get; set; } - - /// <summary> - /// Limit results to items containing specific studios. - /// </summary> - /// <value>The studios.</value> - [ApiMember(Name = "Studios", Description = "Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Studios { get; set; } - - [ApiMember(Name = "StudioIds", Description = "Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string StudioIds { get; set; } - - /// <summary> - /// Gets or sets the studios. - /// </summary> - /// <value>The studios.</value> - [ApiMember(Name = "Artists", Description = "Optional. If specified, results will be filtered based on artist. This allows multiple, pipe delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Artists { get; set; } - - public string ExcludeArtistIds { get; set; } - - [ApiMember(Name = "ArtistIds", Description = "Optional. If specified, results will be filtered based on artist. This allows multiple, pipe delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string ArtistIds { get; set; } - - public string AlbumArtistIds { get; set; } - - public string ContributingArtistIds { get; set; } - - [ApiMember(Name = "Albums", Description = "Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Albums { get; set; } - - public string AlbumIds { get; set; } - - /// <summary> - /// Gets or sets the item ids. - /// </summary> - /// <value>The item ids.</value> - [ApiMember(Name = "Ids", Description = "Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Ids { get; set; } - - /// <summary> - /// Gets or sets the video types. - /// </summary> - /// <value>The video types.</value> - [ApiMember(Name = "VideoTypes", Description = "Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string VideoTypes { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the min offical rating. - /// </summary> - /// <value>The min offical rating.</value> - [ApiMember(Name = "MinOfficialRating", Description = "Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string MinOfficialRating { get; set; } - - [ApiMember(Name = "IsLocked", Description = "Optional filter by items that are locked.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool? IsLocked { get; set; } - - [ApiMember(Name = "IsPlaceHolder", Description = "Optional filter by items that are placeholders", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool? IsPlaceHolder { get; set; } - - [ApiMember(Name = "HasOfficialRating", Description = "Optional filter by items that have official ratings", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool? HasOfficialRating { get; set; } - - [ApiMember(Name = "CollapseBoxSetItems", Description = "Whether or not to hide items behind their boxsets.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? CollapseBoxSetItems { get; set; } - - public int? MinWidth { get; set; } - - public int? MinHeight { get; set; } - - public int? MaxWidth { get; set; } - - public int? MaxHeight { get; set; } - - /// <summary> - /// Gets or sets the video formats. - /// </summary> - /// <value>The video formats.</value> - [ApiMember(Name = "Is3D", Description = "Optional filter by items that are 3D, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? Is3D { get; set; } - - /// <summary> - /// Gets or sets the series status. - /// </summary> - /// <value>The series status.</value> - [ApiMember(Name = "SeriesStatus", Description = "Optional filter by Series Status. Allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string SeriesStatus { get; set; } - - [ApiMember(Name = "NameStartsWithOrGreater", Description = "Optional filter by items whose name is sorted equally or greater than a given input string.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string NameStartsWithOrGreater { get; set; } - - [ApiMember(Name = "NameStartsWith", Description = "Optional filter by items whose name is sorted equally than a given input string.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string NameStartsWith { get; set; } - - [ApiMember(Name = "NameLessThan", Description = "Optional filter by items whose name is equally or lesser than a given input string.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string NameLessThan { get; set; } - - public string[] GetGenres() - { - return (Genres ?? string.Empty).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetTags() - { - return (Tags ?? string.Empty).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetOfficialRatings() - { - return (OfficialRatings ?? string.Empty).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetMediaTypes() - { - return (MediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetIncludeItemTypes() - { - return (IncludeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetExcludeItemTypes() - { - return (ExcludeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - public int[] GetYears() - { - return (Years ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray(); - } - - public string[] GetStudios() - { - return (Studios ?? string.Empty).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetPersonTypes() - { - return (PersonTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - public VideoType[] GetVideoTypes() - { - return string.IsNullOrEmpty(VideoTypes) - ? Array.Empty<VideoType>() - : VideoTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(v => Enum.Parse<VideoType>(v, true)).ToArray(); - } - - /// <summary> - /// Gets the filters. - /// </summary> - /// <returns>IEnumerable{ItemFilter}.</returns> - public ItemFilter[] GetFilters() - { - var val = Filters; - - return string.IsNullOrEmpty(val) - ? Array.Empty<ItemFilter>() - : val.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(v => Enum.Parse<ItemFilter>(v, true)).ToArray(); - } - - /// <summary> - /// Gets the image types. - /// </summary> - /// <returns>IEnumerable{ImageType}.</returns> - public ImageType[] GetImageTypes() - { - var val = ImageTypes; - - return string.IsNullOrEmpty(val) - ? Array.Empty<ImageType>() - : val.Split(',').Select(v => Enum.Parse<ImageType>(v, true)).ToArray(); - } - - /// <summary> - /// Gets the order by. - /// </summary> - /// <returns>IEnumerable{ItemSortBy}.</returns> - public ValueTuple<string, SortOrder>[] GetOrderBy() - { - return GetOrderBy(SortBy, SortOrder); - } - - public static ValueTuple<string, SortOrder>[] GetOrderBy(string sortBy, string requestedSortOrder) - { - var val = sortBy; - - if (string.IsNullOrEmpty(val)) - { - return Array.Empty<ValueTuple<string, SortOrder>>(); - } - - var vals = val.Split(','); - if (string.IsNullOrWhiteSpace(requestedSortOrder)) - { - requestedSortOrder = "Ascending"; - } - - var sortOrders = requestedSortOrder.Split(','); - - var result = new ValueTuple<string, SortOrder>[vals.Length]; - - for (var i = 0; i < vals.Length; i++) - { - var sortOrderIndex = sortOrders.Length > i ? i : 0; - - var sortOrderValue = sortOrders.Length > sortOrderIndex ? sortOrders[sortOrderIndex] : null; - var sortOrder = string.Equals(sortOrderValue, "Descending", StringComparison.OrdinalIgnoreCase) - ? MediaBrowser.Model.Entities.SortOrder.Descending - : MediaBrowser.Model.Entities.SortOrder.Ascending; - - result[i] = new ValueTuple<string, SortOrder>(vals[i], sortOrder); - } - - return result; - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/GenresService.cs b/MediaBrowser.Api/UserLibrary/GenresService.cs deleted file mode 100644 index 7bdfbac98..000000000 --- a/MediaBrowser.Api/UserLibrary/GenresService.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - /// <summary> - /// Class GetGenres. - /// </summary> - [Route("/Genres", "GET", Summary = "Gets all genres from a given item, folder, or the entire library")] - public class GetGenres : GetItemsByName - { - } - - /// <summary> - /// Class GetGenre. - /// </summary> - [Route("/Genres/{Name}", "GET", Summary = "Gets a genre, by name")] - public class GetGenre : IReturn<BaseItemDto> - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "The genre name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - /// <summary> - /// Class GenresService. - /// </summary> - [Authenticated] - public class GenresService : BaseItemsByNameService<Genre> - { - public GenresService( - ILogger<GenresService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IUserDataManager userDataRepository, - IDtoService dtoService, - IAuthorizationContext authorizationContext) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - userDataRepository, - dtoService, - authorizationContext) - { - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetGenre request) - { - var result = GetItem(request); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the item. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{BaseItemDto}.</returns> - private BaseItemDto GetItem(GetGenre request) - { - var dtoOptions = GetDtoOptions(AuthorizationContext, request); - - var item = GetGenre(request.Name, LibraryManager, dtoOptions); - - if (!request.UserId.Equals(Guid.Empty)) - { - var user = UserManager.GetUserById(request.UserId); - - return DtoService.GetBaseItemDto(item, dtoOptions, user); - } - - return DtoService.GetBaseItemDto(item, dtoOptions); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetGenres request) - { - var result = GetResultSlim(request); - - return ToOptimizedResult(result); - } - - protected override QueryResult<(BaseItem, ItemCounts)> GetItems(GetItemsByName request, InternalItemsQuery query) - { - var viewType = GetParentItemViewType(request); - - if (string.Equals(viewType, CollectionType.Music) || string.Equals(viewType, CollectionType.MusicVideos)) - { - return LibraryManager.GetMusicGenres(query); - } - - return LibraryManager.GetGenres(query); - } - - /// <summary> - /// Gets all items. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="items">The items.</param> - /// <returns>IEnumerable{Tuple{System.StringFunc{System.Int32}}}.</returns> - protected override IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items) - { - throw new NotImplementedException(); - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/ItemsService.cs b/MediaBrowser.Api/UserLibrary/ItemsService.cs deleted file mode 100644 index 7efe0552c..000000000 --- a/MediaBrowser.Api/UserLibrary/ItemsService.cs +++ /dev/null @@ -1,514 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; - -namespace MediaBrowser.Api.UserLibrary -{ - /// <summary> - /// Class GetItems. - /// </summary> - [Route("/Items", "GET", Summary = "Gets items based on a query.")] - [Route("/Users/{UserId}/Items", "GET", Summary = "Gets items based on a query.")] - public class GetItems : BaseItemsRequest, IReturn<QueryResult<BaseItemDto>> - { - } - - [Route("/Users/{UserId}/Items/Resume", "GET", Summary = "Gets items based on a query.")] - public class GetResumeItems : BaseItemsRequest, IReturn<QueryResult<BaseItemDto>> - { - } - - /// <summary> - /// Class ItemsService. - /// </summary> - [Authenticated] - public class ItemsService : BaseApiService - { - /// <summary> - /// The _user manager. - /// </summary> - private readonly IUserManager _userManager; - - /// <summary> - /// The _library manager. - /// </summary> - private readonly ILibraryManager _libraryManager; - private readonly ILocalizationManager _localization; - - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - - /// <summary> - /// Initializes a new instance of the <see cref="ItemsService" /> class. - /// </summary> - /// <param name="userManager">The user manager.</param> - /// <param name="libraryManager">The library manager.</param> - /// <param name="localization">The localization.</param> - /// <param name="dtoService">The dto service.</param> - public ItemsService( - ILogger<ItemsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - ILocalizationManager localization, - IDtoService dtoService, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _libraryManager = libraryManager; - _localization = localization; - _dtoService = dtoService; - _authContext = authContext; - } - - public object Get(GetResumeItems request) - { - var user = _userManager.GetUserById(request.UserId); - - var parentIdGuid = string.IsNullOrWhiteSpace(request.ParentId) ? Guid.Empty : new Guid(request.ParentId); - - var options = GetDtoOptions(_authContext, request); - - var ancestorIds = Array.Empty<Guid>(); - - var excludeFolderIds = user.GetPreference(PreferenceKind.LatestItemExcludes); - if (parentIdGuid.Equals(Guid.Empty) && excludeFolderIds.Length > 0) - { - ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) - .Where(i => i is Folder) - .Where(i => !excludeFolderIds.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))) - .Select(i => i.Id) - .ToArray(); - } - - var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) - { - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, - IsResumable = true, - StartIndex = request.StartIndex, - Limit = request.Limit, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = options, - MediaTypes = request.GetMediaTypes(), - IsVirtualItem = false, - CollapseBoxSetItems = false, - EnableTotalRecordCount = request.EnableTotalRecordCount, - AncestorIds = ancestorIds, - IncludeItemTypes = request.GetIncludeItemTypes(), - ExcludeItemTypes = request.GetExcludeItemTypes(), - SearchTerm = request.SearchTerm - }); - - var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, options, user); - - var result = new QueryResult<BaseItemDto> - { - StartIndex = request.StartIndex.GetValueOrDefault(), - TotalRecordCount = itemsResult.TotalRecordCount, - Items = returnItems - }; - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetItems request) - { - if (request == null) - { - throw new ArgumentNullException(nameof(request)); - } - - var result = GetItems(request); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the items. - /// </summary> - /// <param name="request">The request.</param> - private QueryResult<BaseItemDto> GetItems(GetItems request) - { - var user = request.UserId == Guid.Empty ? null : _userManager.GetUserById(request.UserId); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = GetQueryResult(request, dtoOptions, user); - - if (result == null) - { - throw new InvalidOperationException("GetItemsToSerialize returned null"); - } - - if (result.Items == null) - { - throw new InvalidOperationException("GetItemsToSerialize result.Items returned null"); - } - - var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); - - return new QueryResult<BaseItemDto> - { - StartIndex = request.StartIndex.GetValueOrDefault(), - TotalRecordCount = result.TotalRecordCount, - Items = dtoList - }; - } - - /// <summary> - /// Gets the items to serialize. - /// </summary> - private QueryResult<BaseItem> GetQueryResult(GetItems request, DtoOptions dtoOptions, User user) - { - if (string.Equals(request.IncludeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase) - || string.Equals(request.IncludeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase)) - { - request.ParentId = null; - } - - BaseItem item = null; - - if (!string.IsNullOrEmpty(request.ParentId)) - { - item = _libraryManager.GetItemById(request.ParentId); - } - - if (item == null) - { - item = _libraryManager.GetUserRootFolder(); - } - - if (!(item is Folder folder)) - { - folder = _libraryManager.GetUserRootFolder(); - } - - if (folder is IHasCollectionType hasCollectionType - && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) - { - request.Recursive = true; - request.IncludeItemTypes = "Playlist"; - } - - bool isInEnabledFolder = user.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id) - // Assume all folders inside an EnabledChannel are enabled - || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id); - - var collectionFolders = _libraryManager.GetCollectionFolders(item); - foreach (var collectionFolder in collectionFolders) - { - if (user.GetPreference(PreferenceKind.EnabledFolders).Contains( - collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture), - StringComparer.OrdinalIgnoreCase)) - { - isInEnabledFolder = true; - } - } - - if (!(item is UserRootFolder) - && !isInEnabledFolder - && !user.HasPermission(PermissionKind.EnableAllFolders) - && !user.HasPermission(PermissionKind.EnableAllChannels)) - { - Logger.LogWarning("{UserName} is not permitted to access Library {ItemName}.", user.Username, item.Name); - return new QueryResult<BaseItem> - { - Items = Array.Empty<BaseItem>(), - TotalRecordCount = 0, - StartIndex = 0 - }; - } - - if (request.Recursive || !string.IsNullOrEmpty(request.Ids) || !(item is UserRootFolder)) - { - return folder.GetItems(GetItemsQuery(request, dtoOptions, user)); - } - - var itemsArray = folder.GetChildren(user, true); - return new QueryResult<BaseItem> - { - Items = itemsArray, - TotalRecordCount = itemsArray.Count, - StartIndex = 0 - }; - } - - private InternalItemsQuery GetItemsQuery(GetItems request, DtoOptions dtoOptions, User user) - { - var query = new InternalItemsQuery(user) - { - IsPlayed = request.IsPlayed, - MediaTypes = request.GetMediaTypes(), - IncludeItemTypes = request.GetIncludeItemTypes(), - ExcludeItemTypes = request.GetExcludeItemTypes(), - Recursive = request.Recursive, - OrderBy = request.GetOrderBy(), - - IsFavorite = request.IsFavorite, - Limit = request.Limit, - StartIndex = request.StartIndex, - IsMissing = request.IsMissing, - IsUnaired = request.IsUnaired, - CollapseBoxSetItems = request.CollapseBoxSetItems, - NameLessThan = request.NameLessThan, - NameStartsWith = request.NameStartsWith, - NameStartsWithOrGreater = request.NameStartsWithOrGreater, - HasImdbId = request.HasImdbId, - IsPlaceHolder = request.IsPlaceHolder, - IsLocked = request.IsLocked, - MinWidth = request.MinWidth, - MinHeight = request.MinHeight, - MaxWidth = request.MaxWidth, - MaxHeight = request.MaxHeight, - Is3D = request.Is3D, - HasTvdbId = request.HasTvdbId, - HasTmdbId = request.HasTmdbId, - HasOverview = request.HasOverview, - HasOfficialRating = request.HasOfficialRating, - HasParentalRating = request.HasParentalRating, - HasSpecialFeature = request.HasSpecialFeature, - HasSubtitles = request.HasSubtitles, - HasThemeSong = request.HasThemeSong, - HasThemeVideo = request.HasThemeVideo, - HasTrailer = request.HasTrailer, - IsHD = request.IsHD, - Is4K = request.Is4K, - Tags = request.GetTags(), - OfficialRatings = request.GetOfficialRatings(), - Genres = request.GetGenres(), - ArtistIds = GetGuids(request.ArtistIds), - AlbumArtistIds = GetGuids(request.AlbumArtistIds), - ContributingArtistIds = GetGuids(request.ContributingArtistIds), - GenreIds = GetGuids(request.GenreIds), - StudioIds = GetGuids(request.StudioIds), - Person = request.Person, - PersonIds = GetGuids(request.PersonIds), - PersonTypes = request.GetPersonTypes(), - Years = request.GetYears(), - ImageTypes = request.GetImageTypes(), - VideoTypes = request.GetVideoTypes(), - AdjacentTo = request.AdjacentTo, - ItemIds = GetGuids(request.Ids), - MinCommunityRating = request.MinCommunityRating, - MinCriticRating = request.MinCriticRating, - ParentId = string.IsNullOrWhiteSpace(request.ParentId) ? Guid.Empty : new Guid(request.ParentId), - ParentIndexNumber = request.ParentIndexNumber, - EnableTotalRecordCount = request.EnableTotalRecordCount, - ExcludeItemIds = GetGuids(request.ExcludeItemIds), - DtoOptions = dtoOptions, - SearchTerm = request.SearchTerm - }; - - if (!string.IsNullOrWhiteSpace(request.Ids) || !string.IsNullOrWhiteSpace(request.SearchTerm)) - { - query.CollapseBoxSetItems = false; - } - - foreach (var filter in request.GetFilters()) - { - 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; - } - } - - if (!string.IsNullOrEmpty(request.MinDateLastSaved)) - { - query.MinDateLastSaved = DateTime.Parse(request.MinDateLastSaved, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - } - - if (!string.IsNullOrEmpty(request.MinDateLastSavedForUser)) - { - query.MinDateLastSavedForUser = DateTime.Parse(request.MinDateLastSavedForUser, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - } - - if (!string.IsNullOrEmpty(request.MinPremiereDate)) - { - query.MinPremiereDate = DateTime.Parse(request.MinPremiereDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - } - - if (!string.IsNullOrEmpty(request.MaxPremiereDate)) - { - query.MaxPremiereDate = DateTime.Parse(request.MaxPremiereDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - } - - // Filter by Series Status - if (!string.IsNullOrEmpty(request.SeriesStatus)) - { - query.SeriesStatuses = request.SeriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray(); - } - - // ExcludeLocationTypes - if (!string.IsNullOrEmpty(request.ExcludeLocationTypes)) - { - var excludeLocationTypes = request.ExcludeLocationTypes.Split(',').Select(d => (LocationType)Enum.Parse(typeof(LocationType), d, true)).ToArray(); - if (excludeLocationTypes.Contains(LocationType.Virtual)) - { - query.IsVirtualItem = false; - } - } - - if (!string.IsNullOrEmpty(request.LocationTypes)) - { - var requestedLocationTypes = - request.LocationTypes.Split(','); - - if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4) - { - query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString()); - } - } - - // Min official rating - if (!string.IsNullOrWhiteSpace(request.MinOfficialRating)) - { - query.MinParentalRating = _localization.GetRatingLevel(request.MinOfficialRating); - } - - // Max official rating - if (!string.IsNullOrWhiteSpace(request.MaxOfficialRating)) - { - query.MaxParentalRating = _localization.GetRatingLevel(request.MaxOfficialRating); - } - - // Artists - if (!string.IsNullOrEmpty(request.Artists)) - { - query.ArtistIds = request.Artists.Split('|').Select(i => - { - try - { - return _libraryManager.GetArtist(i, new DtoOptions(false)); - } - catch - { - return null; - } - }).Where(i => i != null).Select(i => i.Id).ToArray(); - } - - // ExcludeArtistIds - if (!string.IsNullOrWhiteSpace(request.ExcludeArtistIds)) - { - query.ExcludeArtistIds = GetGuids(request.ExcludeArtistIds); - } - - if (!string.IsNullOrWhiteSpace(request.AlbumIds)) - { - query.AlbumIds = GetGuids(request.AlbumIds); - } - - // Albums - if (!string.IsNullOrEmpty(request.Albums)) - { - query.AlbumIds = request.Albums.Split('|').SelectMany(i => - { - return _libraryManager.GetItemIds(new InternalItemsQuery - { - IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, - Name = i, - Limit = 1 - }); - }).ToArray(); - } - - // Studios - if (!string.IsNullOrEmpty(request.Studios)) - { - query.StudioIds = request.Studios.Split('|').Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null).Select(i => i.Id).ToArray(); - } - - // Apply default sorting if none requested - if (query.OrderBy.Count == 0) - { - // Albums by artist - if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], "MusicAlbum", StringComparison.OrdinalIgnoreCase)) - { - query.OrderBy = new[] - { - new ValueTuple<string, SortOrder>(ItemSortBy.ProductionYear, SortOrder.Descending), - new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) - }; - } - } - - return query; - } - } - - /// <summary> - /// Class DateCreatedComparer. - /// </summary> - public class DateCreatedComparer : IComparer<BaseItem> - { - /// <summary> - /// Compares the specified x. - /// </summary> - /// <param name="x">The x.</param> - /// <param name="y">The y.</param> - /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) - { - return x.DateCreated.CompareTo(y.DateCreated); - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/MusicGenresService.cs b/MediaBrowser.Api/UserLibrary/MusicGenresService.cs deleted file mode 100644 index e9caca14a..000000000 --- a/MediaBrowser.Api/UserLibrary/MusicGenresService.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - [Route("/MusicGenres", "GET", Summary = "Gets all music genres from a given item, folder, or the entire library")] - public class GetMusicGenres : GetItemsByName - { - } - - [Route("/MusicGenres/{Name}", "GET", Summary = "Gets a music genre, by name")] - public class GetMusicGenre : IReturn<BaseItemDto> - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "The genre name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - [Authenticated] - public class MusicGenresService : BaseItemsByNameService<MusicGenre> - { - public MusicGenresService( - ILogger<MusicGenresService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IUserDataManager userDataRepository, - IDtoService dtoService, - IAuthorizationContext authorizationContext) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - userDataRepository, - dtoService, - authorizationContext) - { - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetMusicGenre request) - { - var result = GetItem(request); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the item. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{BaseItemDto}.</returns> - private BaseItemDto GetItem(GetMusicGenre request) - { - var dtoOptions = GetDtoOptions(AuthorizationContext, request); - - var item = GetMusicGenre(request.Name, LibraryManager, dtoOptions); - - if (!request.UserId.Equals(Guid.Empty)) - { - var user = UserManager.GetUserById(request.UserId); - - return DtoService.GetBaseItemDto(item, dtoOptions, user); - } - - return DtoService.GetBaseItemDto(item, dtoOptions); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetMusicGenres request) - { - var result = GetResultSlim(request); - - return ToOptimizedResult(result); - } - - protected override QueryResult<(BaseItem, ItemCounts)> GetItems(GetItemsByName request, InternalItemsQuery query) - { - return LibraryManager.GetMusicGenres(query); - } - - /// <summary> - /// Gets all items. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="items">The items.</param> - /// <returns>IEnumerable{Tuple{System.StringFunc{System.Int32}}}.</returns> - protected override IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items) - { - throw new NotImplementedException(); - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/PersonsService.cs b/MediaBrowser.Api/UserLibrary/PersonsService.cs deleted file mode 100644 index 7924339ed..000000000 --- a/MediaBrowser.Api/UserLibrary/PersonsService.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - /// <summary> - /// Class GetPersons. - /// </summary> - [Route("/Persons", "GET", Summary = "Gets all persons from a given item, folder, or the entire library")] - public class GetPersons : GetItemsByName - { - } - - /// <summary> - /// Class GetPerson. - /// </summary> - [Route("/Persons/{Name}", "GET", Summary = "Gets a person, by name")] - public class GetPerson : IReturn<BaseItemDto> - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "The person name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - /// <summary> - /// Class PersonsService. - /// </summary> - [Authenticated] - public class PersonsService : BaseItemsByNameService<Person> - { - public PersonsService( - ILogger<PersonsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IUserDataManager userDataRepository, - IDtoService dtoService, - IAuthorizationContext authorizationContext) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - userDataRepository, - dtoService, - authorizationContext) - { - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetPerson request) - { - var result = GetItem(request); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the item. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{BaseItemDto}.</returns> - private BaseItemDto GetItem(GetPerson request) - { - var dtoOptions = GetDtoOptions(AuthorizationContext, request); - - var item = GetPerson(request.Name, LibraryManager, dtoOptions); - - if (!request.UserId.Equals(Guid.Empty)) - { - var user = UserManager.GetUserById(request.UserId); - - return DtoService.GetBaseItemDto(item, dtoOptions, user); - } - - return DtoService.GetBaseItemDto(item, dtoOptions); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetPersons request) - { - return GetResultSlim(request); - } - - /// <summary> - /// Gets all items. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="items">The items.</param> - /// <returns>IEnumerable{Tuple{System.StringFunc{System.Int32}}}.</returns> - protected override IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items) - { - throw new NotImplementedException(); - } - - protected override QueryResult<(BaseItem, ItemCounts)> GetItems(GetItemsByName request, InternalItemsQuery query) - { - var items = LibraryManager.GetPeopleItems(new InternalPeopleQuery - { - PersonTypes = query.PersonTypes, - NameContains = query.NameContains ?? query.SearchTerm - }); - - if ((query.IsFavorite ?? false) && query.User != null) - { - items = items.Where(i => UserDataRepository.GetUserData(query.User, i).IsFavorite).ToList(); - } - - return new QueryResult<(BaseItem, ItemCounts)> - { - TotalRecordCount = items.Count, - Items = items.Take(query.Limit ?? int.MaxValue).Select(i => (i as BaseItem, new ItemCounts())).ToArray() - }; - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/PlaystateService.cs b/MediaBrowser.Api/UserLibrary/PlaystateService.cs deleted file mode 100644 index d809cc2e7..000000000 --- a/MediaBrowser.Api/UserLibrary/PlaystateService.cs +++ /dev/null @@ -1,456 +0,0 @@ -using System; -using System.Globalization; -using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.Session; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - /// <summary> - /// Class MarkPlayedItem. - /// </summary> - [Route("/Users/{UserId}/PlayedItems/{Id}", "POST", Summary = "Marks an item as played")] - public class MarkPlayedItem : IReturn<UserItemDataDto> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string UserId { get; set; } - - [ApiMember(Name = "DatePlayed", Description = "The date the item was played (if any). Format = yyyyMMddHHmmss", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string DatePlayed { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - /// <summary> - /// Class MarkUnplayedItem. - /// </summary> - [Route("/Users/{UserId}/PlayedItems/{Id}", "DELETE", Summary = "Marks an item as unplayed")] - public class MarkUnplayedItem : IReturn<UserItemDataDto> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/Sessions/Playing", "POST", Summary = "Reports playback has started within a session")] - public class ReportPlaybackStart : PlaybackStartInfo, IReturnVoid - { - } - - [Route("/Sessions/Playing/Progress", "POST", Summary = "Reports playback progress within a session")] - public class ReportPlaybackProgress : PlaybackProgressInfo, IReturnVoid - { - } - - [Route("/Sessions/Playing/Ping", "POST", Summary = "Pings a playback session")] - public class PingPlaybackSession : IReturnVoid - { - [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string PlaySessionId { get; set; } - } - - [Route("/Sessions/Playing/Stopped", "POST", Summary = "Reports playback has stopped within a session")] - public class ReportPlaybackStopped : PlaybackStopInfo, IReturnVoid - { - } - - /// <summary> - /// Class OnPlaybackStart. - /// </summary> - [Route("/Users/{UserId}/PlayingItems/{Id}", "POST", Summary = "Reports that a user has begun playing an item")] - public class OnPlaybackStart : IReturnVoid - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "MediaSourceId", Description = "The id of the MediaSource", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string MediaSourceId { get; set; } - - [ApiMember(Name = "CanSeek", Description = "Indicates if the client can seek", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public bool CanSeek { get; set; } - - [ApiMember(Name = "AudioStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")] - public int? AudioStreamIndex { get; set; } - - [ApiMember(Name = "SubtitleStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")] - public int? SubtitleStreamIndex { get; set; } - - [ApiMember(Name = "PlayMethod", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public PlayMethod PlayMethod { get; set; } - - [ApiMember(Name = "LiveStreamId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string LiveStreamId { get; set; } - - [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string PlaySessionId { get; set; } - } - - /// <summary> - /// Class OnPlaybackProgress. - /// </summary> - [Route("/Users/{UserId}/PlayingItems/{Id}/Progress", "POST", Summary = "Reports a user's playback progress")] - public class OnPlaybackProgress : IReturnVoid - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "MediaSourceId", Description = "The id of the MediaSource", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string MediaSourceId { get; set; } - - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - [ApiMember(Name = "PositionTicks", Description = "Optional. The current position, in ticks. 1 tick = 10000 ms", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")] - public long? PositionTicks { get; set; } - - [ApiMember(Name = "IsPaused", Description = "Indicates if the player is paused.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public bool IsPaused { get; set; } - - [ApiMember(Name = "IsMuted", Description = "Indicates if the player is muted.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public bool IsMuted { get; set; } - - [ApiMember(Name = "AudioStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")] - public int? AudioStreamIndex { get; set; } - - [ApiMember(Name = "SubtitleStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")] - public int? SubtitleStreamIndex { get; set; } - - [ApiMember(Name = "VolumeLevel", Description = "Scale of 0-100", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")] - public int? VolumeLevel { get; set; } - - [ApiMember(Name = "PlayMethod", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public PlayMethod PlayMethod { get; set; } - - [ApiMember(Name = "LiveStreamId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string LiveStreamId { get; set; } - - [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string PlaySessionId { get; set; } - - [ApiMember(Name = "RepeatMode", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public RepeatMode RepeatMode { get; set; } - } - - /// <summary> - /// Class OnPlaybackStopped. - /// </summary> - [Route("/Users/{UserId}/PlayingItems/{Id}", "DELETE", Summary = "Reports that a user has stopped playing an item")] - public class OnPlaybackStopped : IReturnVoid - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - - [ApiMember(Name = "MediaSourceId", Description = "The id of the MediaSource", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string MediaSourceId { get; set; } - - [ApiMember(Name = "NextMediaType", Description = "The next media type that will play", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string NextMediaType { get; set; } - - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - [ApiMember(Name = "PositionTicks", Description = "Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "DELETE")] - public long? PositionTicks { get; set; } - - [ApiMember(Name = "LiveStreamId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string LiveStreamId { get; set; } - - [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string PlaySessionId { get; set; } - } - - [Authenticated] - public class PlaystateService : BaseApiService - { - private readonly IUserManager _userManager; - private readonly IUserDataManager _userDataRepository; - private readonly ILibraryManager _libraryManager; - private readonly ISessionManager _sessionManager; - private readonly ISessionContext _sessionContext; - private readonly IAuthorizationContext _authContext; - - public PlaystateService( - ILogger<PlaystateService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - IUserDataManager userDataRepository, - ILibraryManager libraryManager, - ISessionManager sessionManager, - ISessionContext sessionContext, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _userDataRepository = userDataRepository; - _libraryManager = libraryManager; - _sessionManager = sessionManager; - _sessionContext = sessionContext; - _authContext = authContext; - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Post(MarkPlayedItem request) - { - var result = MarkPlayed(request); - - return ToOptimizedResult(result); - } - - private UserItemDataDto MarkPlayed(MarkPlayedItem request) - { - var user = _userManager.GetUserById(Guid.Parse(request.UserId)); - - DateTime? datePlayed = null; - - if (!string.IsNullOrEmpty(request.DatePlayed)) - { - datePlayed = DateTime.ParseExact(request.DatePlayed, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); - } - - var session = GetSession(_sessionContext); - - var dto = UpdatePlayedStatus(user, request.Id, true, datePlayed); - - foreach (var additionalUserInfo in session.AdditionalUsers) - { - var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); - - UpdatePlayedStatus(additionalUser, request.Id, true, datePlayed); - } - - return dto; - } - - private PlayMethod ValidatePlayMethod(PlayMethod method, string playSessionId) - { - if (method == PlayMethod.Transcode) - { - var job = string.IsNullOrWhiteSpace(playSessionId) ? null : ApiEntryPoint.Instance.GetTranscodingJob(playSessionId); - if (job == null) - { - return PlayMethod.DirectPlay; - } - } - - return method; - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(OnPlaybackStart request) - { - Post(new ReportPlaybackStart - { - CanSeek = request.CanSeek, - ItemId = new Guid(request.Id), - MediaSourceId = request.MediaSourceId, - AudioStreamIndex = request.AudioStreamIndex, - SubtitleStreamIndex = request.SubtitleStreamIndex, - PlayMethod = request.PlayMethod, - PlaySessionId = request.PlaySessionId, - LiveStreamId = request.LiveStreamId - }); - } - - public void Post(ReportPlaybackStart request) - { - request.PlayMethod = ValidatePlayMethod(request.PlayMethod, request.PlaySessionId); - - request.SessionId = GetSession(_sessionContext).Id; - - var task = _sessionManager.OnPlaybackStart(request); - - Task.WaitAll(task); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(OnPlaybackProgress request) - { - Post(new ReportPlaybackProgress - { - ItemId = new Guid(request.Id), - PositionTicks = request.PositionTicks, - IsMuted = request.IsMuted, - IsPaused = request.IsPaused, - MediaSourceId = request.MediaSourceId, - AudioStreamIndex = request.AudioStreamIndex, - SubtitleStreamIndex = request.SubtitleStreamIndex, - VolumeLevel = request.VolumeLevel, - PlayMethod = request.PlayMethod, - PlaySessionId = request.PlaySessionId, - LiveStreamId = request.LiveStreamId, - RepeatMode = request.RepeatMode - }); - } - - public void Post(ReportPlaybackProgress request) - { - request.PlayMethod = ValidatePlayMethod(request.PlayMethod, request.PlaySessionId); - - request.SessionId = GetSession(_sessionContext).Id; - - var task = _sessionManager.OnPlaybackProgress(request); - - Task.WaitAll(task); - } - - public void Post(PingPlaybackSession request) - { - ApiEntryPoint.Instance.PingTranscodingJob(request.PlaySessionId, null); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Delete(OnPlaybackStopped request) - { - return Post(new ReportPlaybackStopped - { - ItemId = new Guid(request.Id), - PositionTicks = request.PositionTicks, - MediaSourceId = request.MediaSourceId, - PlaySessionId = request.PlaySessionId, - LiveStreamId = request.LiveStreamId, - NextMediaType = request.NextMediaType - }); - } - - public async Task Post(ReportPlaybackStopped request) - { - Logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", request.PlaySessionId ?? string.Empty); - - if (!string.IsNullOrWhiteSpace(request.PlaySessionId)) - { - await ApiEntryPoint.Instance.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, request.PlaySessionId, s => true); - } - - request.SessionId = GetSession(_sessionContext).Id; - - await _sessionManager.OnPlaybackStopped(request); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Delete(MarkUnplayedItem request) - { - var task = MarkUnplayed(request); - - return ToOptimizedResult(task); - } - - private UserItemDataDto MarkUnplayed(MarkUnplayedItem request) - { - var user = _userManager.GetUserById(Guid.Parse(request.UserId)); - - var session = GetSession(_sessionContext); - - var dto = UpdatePlayedStatus(user, request.Id, false, null); - - foreach (var additionalUserInfo in session.AdditionalUsers) - { - var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); - - UpdatePlayedStatus(additionalUser, request.Id, false, null); - } - - return dto; - } - - /// <summary> - /// Updates the played status. - /// </summary> - /// <param name="user">The user.</param> - /// <param name="itemId">The item id.</param> - /// <param name="wasPlayed">if set to <c>true</c> [was played].</param> - /// <param name="datePlayed">The date played.</param> - /// <returns>Task.</returns> - private UserItemDataDto UpdatePlayedStatus(User user, string itemId, bool wasPlayed, DateTime? datePlayed) - { - var item = _libraryManager.GetItemById(itemId); - - if (wasPlayed) - { - item.MarkPlayed(user, datePlayed, true); - } - else - { - item.MarkUnplayed(user); - } - - return _userDataRepository.GetUserDataDto(item, user); - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/StudiosService.cs b/MediaBrowser.Api/UserLibrary/StudiosService.cs deleted file mode 100644 index 66350955f..000000000 --- a/MediaBrowser.Api/UserLibrary/StudiosService.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - /// <summary> - /// Class GetStudios. - /// </summary> - [Route("/Studios", "GET", Summary = "Gets all studios from a given item, folder, or the entire library")] - public class GetStudios : GetItemsByName - { - } - - /// <summary> - /// Class GetStudio. - /// </summary> - [Route("/Studios/{Name}", "GET", Summary = "Gets a studio, by name")] - public class GetStudio : IReturn<BaseItemDto> - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - [ApiMember(Name = "Name", Description = "The studio name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - /// <summary> - /// Class StudiosService. - /// </summary> - [Authenticated] - public class StudiosService : BaseItemsByNameService<Studio> - { - public StudiosService( - ILogger<StudiosService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IUserDataManager userDataRepository, - IDtoService dtoService, - IAuthorizationContext authorizationContext) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - userDataRepository, - dtoService, - authorizationContext) - { - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetStudio request) - { - var result = GetItem(request); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the item. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{BaseItemDto}.</returns> - private BaseItemDto GetItem(GetStudio request) - { - var dtoOptions = GetDtoOptions(AuthorizationContext, request); - - var item = GetStudio(request.Name, LibraryManager, dtoOptions); - - if (!request.UserId.Equals(Guid.Empty)) - { - var user = UserManager.GetUserById(request.UserId); - - return DtoService.GetBaseItemDto(item, dtoOptions, user); - } - - return DtoService.GetBaseItemDto(item, dtoOptions); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetStudios request) - { - var result = GetResultSlim(request); - - return ToOptimizedResult(result); - } - - protected override QueryResult<(BaseItem, ItemCounts)> GetItems(GetItemsByName request, InternalItemsQuery query) - { - return LibraryManager.GetStudios(query); - } - - /// <summary> - /// Gets all items. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="items">The items.</param> - /// <returns>IEnumerable{Tuple{System.StringFunc{System.Int32}}}.</returns> - protected override IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items) - { - throw new NotImplementedException(); - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/UserLibraryService.cs b/MediaBrowser.Api/UserLibrary/UserLibraryService.cs deleted file mode 100644 index f9cbba410..000000000 --- a/MediaBrowser.Api/UserLibrary/UserLibraryService.cs +++ /dev/null @@ -1,575 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - /// <summary> - /// Class GetItem. - /// </summary> - [Route("/Users/{UserId}/Items/{Id}", "GET", Summary = "Gets an item from a user's library")] - public class GetItem : IReturn<BaseItemDto> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - /// <summary> - /// Class GetItem. - /// </summary> - [Route("/Users/{UserId}/Items/Root", "GET", Summary = "Gets the root folder from a user's library")] - public class GetRootFolder : IReturn<BaseItemDto> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid UserId { get; set; } - } - - /// <summary> - /// Class GetIntros. - /// </summary> - [Route("/Users/{UserId}/Items/{Id}/Intros", "GET", Summary = "Gets intros to play before the main media item plays")] - public class GetIntros : IReturn<QueryResult<BaseItemDto>> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the item id. - /// </summary> - /// <value>The item id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - /// <summary> - /// Class MarkFavoriteItem. - /// </summary> - [Route("/Users/{UserId}/FavoriteItems/{Id}", "POST", Summary = "Marks an item as a favorite")] - public class MarkFavoriteItem : IReturn<UserItemDataDto> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class UnmarkFavoriteItem. - /// </summary> - [Route("/Users/{UserId}/FavoriteItems/{Id}", "DELETE", Summary = "Unmarks an item as a favorite")] - public class UnmarkFavoriteItem : IReturn<UserItemDataDto> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class ClearUserItemRating. - /// </summary> - [Route("/Users/{UserId}/Items/{Id}/Rating", "DELETE", Summary = "Deletes a user's saved personal rating for an item")] - public class DeleteUserItemRating : IReturn<UserItemDataDto> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class UpdateUserItemRating. - /// </summary> - [Route("/Users/{UserId}/Items/{Id}/Rating", "POST", Summary = "Updates a user's rating for an item")] - public class UpdateUserItemRating : IReturn<UserItemDataDto> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this <see cref="UpdateUserItemRating" /> is likes. - /// </summary> - /// <value><c>true</c> if likes; otherwise, <c>false</c>.</value> - [ApiMember(Name = "Likes", Description = "Whether the user likes the item or not. true/false", IsRequired = true, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public bool Likes { get; set; } - } - - /// <summary> - /// Class GetLocalTrailers. - /// </summary> - [Route("/Users/{UserId}/Items/{Id}/LocalTrailers", "GET", Summary = "Gets local trailers for an item")] - public class GetLocalTrailers : IReturn<BaseItemDto[]> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - /// <summary> - /// Class GetSpecialFeatures. - /// </summary> - [Route("/Users/{UserId}/Items/{Id}/SpecialFeatures", "GET", Summary = "Gets special features for an item")] - public class GetSpecialFeatures : IReturn<BaseItemDto[]> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Movie Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Users/{UserId}/Items/Latest", "GET", Summary = "Gets latest media")] - public class GetLatestMedia : IReturn<BaseItemDto[]>, IHasDtoOptions - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid UserId { get; set; } - - [ApiMember(Name = "Limit", Description = "Limit", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int Limit { get; set; } - - [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid ParentId { get; set; } - - [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - [ApiMember(Name = "IncludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string IncludeItemTypes { get; set; } - - [ApiMember(Name = "IsFolder", Description = "Filter by items that are folders, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsFolder { get; set; } - - [ApiMember(Name = "IsPlayed", Description = "Filter by items that are played, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsPlayed { get; set; } - - [ApiMember(Name = "GroupItems", Description = "Whether or not to group items into a parent container.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool GroupItems { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - public GetLatestMedia() - { - Limit = 20; - GroupItems = true; - } - } - - /// <summary> - /// Class UserLibraryService. - /// </summary> - [Authenticated] - public class UserLibraryService : BaseApiService - { - private readonly IUserManager _userManager; - private readonly IUserDataManager _userDataRepository; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IUserViewManager _userViewManager; - private readonly IFileSystem _fileSystem; - private readonly IAuthorizationContext _authContext; - - public UserLibraryService( - ILogger<UserLibraryService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IUserDataManager userDataRepository, - IDtoService dtoService, - IUserViewManager userViewManager, - IFileSystem fileSystem, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _libraryManager = libraryManager; - _userDataRepository = userDataRepository; - _dtoService = dtoService; - _userViewManager = userViewManager; - _fileSystem = fileSystem; - _authContext = authContext; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetSpecialFeatures request) - { - var result = GetAsync(request); - - return ToOptimizedResult(result); - } - - public object Get(GetLatestMedia request) - { - var user = _userManager.GetUserById(request.UserId); - - if (!request.IsPlayed.HasValue) - { - if (user.HidePlayedInLatest) - { - request.IsPlayed = false; - } - } - - var dtoOptions = GetDtoOptions(_authContext, request); - - var list = _userViewManager.GetLatestItems(new LatestItemsQuery - { - GroupItems = request.GroupItems, - IncludeItemTypes = ApiEntryPoint.Split(request.IncludeItemTypes, ',', true), - IsPlayed = request.IsPlayed, - Limit = request.Limit, - ParentId = request.ParentId, - UserId = request.UserId, - }, dtoOptions); - - var dtos = list.Select(i => - { - var item = i.Item2[0]; - var childCount = 0; - - if (i.Item1 != null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum)) - { - item = i.Item1; - childCount = i.Item2.Count; - } - - var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user); - - dto.ChildCount = childCount; - - return dto; - }); - - return ToOptimizedResult(dtos.ToArray()); - } - - private BaseItemDto[] GetAsync(GetSpecialFeatures request) - { - var user = _userManager.GetUserById(request.UserId); - - var item = string.IsNullOrEmpty(request.Id) ? - _libraryManager.GetUserRootFolder() : - _libraryManager.GetItemById(request.Id); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var dtos = item - .GetExtras(BaseItem.DisplayExtraTypes) - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)); - - return dtos.ToArray(); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetLocalTrailers request) - { - var user = _userManager.GetUserById(request.UserId); - - var item = string.IsNullOrEmpty(request.Id) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(request.Id); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var dtosExtras = item.GetExtras(new[] { ExtraType.Trailer }) - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); - - if (item is IHasTrailers hasTrailers) - { - var trailers = hasTrailers.GetTrailers(); - var dtosTrailers = _dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item); - var allTrailers = new BaseItemDto[dtosExtras.Length + dtosTrailers.Count]; - dtosExtras.CopyTo(allTrailers, 0); - dtosTrailers.CopyTo(allTrailers, dtosExtras.Length); - return ToOptimizedResult(allTrailers); - } - - return ToOptimizedResult(dtosExtras); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Get(GetItem request) - { - var user = _userManager.GetUserById(request.UserId); - - var item = string.IsNullOrEmpty(request.Id) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(request.Id); - - await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = _dtoService.GetBaseItemDto(item, dtoOptions, user); - - return ToOptimizedResult(result); - } - - private async Task RefreshItemOnDemandIfNeeded(BaseItem item) - { - if (item is Person) - { - var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); - var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; - - if (!hasMetdata) - { - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = performFullRefresh - }; - - await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); - } - } - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetRootFolder request) - { - var user = _userManager.GetUserById(request.UserId); - - var item = _libraryManager.GetUserRootFolder(); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = _dtoService.GetBaseItemDto(item, dtoOptions, user); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Get(GetIntros request) - { - var user = _userManager.GetUserById(request.UserId); - - var item = string.IsNullOrEmpty(request.Id) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(request.Id); - - var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); - - var result = new QueryResult<BaseItemDto> - { - Items = dtos, - TotalRecordCount = dtos.Length - }; - - return ToOptimizedResult(result); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Post(MarkFavoriteItem request) - { - var dto = MarkFavorite(request.UserId, request.Id, true); - - return ToOptimizedResult(dto); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Delete(UnmarkFavoriteItem request) - { - var dto = MarkFavorite(request.UserId, request.Id, false); - - return ToOptimizedResult(dto); - } - - /// <summary> - /// Marks the favorite. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="itemId">The item id.</param> - /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param> - private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite) - { - var user = _userManager.GetUserById(userId); - - var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); - - // Get the user data for this item - var data = _userDataRepository.GetUserData(user, item); - - // Set favorite status - data.IsFavorite = isFavorite; - - _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); - - return _userDataRepository.GetUserDataDto(item, user); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Delete(DeleteUserItemRating request) - { - var dto = UpdateUserItemRating(request.UserId, request.Id, null); - - return ToOptimizedResult(dto); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Post(UpdateUserItemRating request) - { - var dto = UpdateUserItemRating(request.UserId, request.Id, request.Likes); - - return ToOptimizedResult(dto); - } - - /// <summary> - /// Updates the user item rating. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="itemId">The item id.</param> - /// <param name="likes">if set to <c>true</c> [likes].</param> - private UserItemDataDto UpdateUserItemRating(Guid userId, Guid itemId, bool? likes) - { - var user = _userManager.GetUserById(userId); - - var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); - - // Get the user data for this item - var data = _userDataRepository.GetUserData(user, item); - - data.Likes = likes; - - _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); - - return _userDataRepository.GetUserDataDto(item, user); - } - } -} diff --git a/MediaBrowser.Api/UserLibrary/UserViewsService.cs b/MediaBrowser.Api/UserLibrary/UserViewsService.cs deleted file mode 100644 index 6f1620ddd..000000000 --- a/MediaBrowser.Api/UserLibrary/UserViewsService.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Library; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - [Route("/Users/{UserId}/Views", "GET")] - public class GetUserViews : IReturn<QueryResult<BaseItemDto>> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid UserId { get; set; } - - [ApiMember(Name = "IncludeExternalContent", Description = "Whether or not to include external views such as channels or live tv", IsRequired = true, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? IncludeExternalContent { get; set; } - - public bool IncludeHidden { get; set; } - - public string PresetViews { get; set; } - } - - [Route("/Users/{UserId}/GroupingOptions", "GET")] - public class GetGroupingOptions : IReturn<SpecialViewOption[]> - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid UserId { get; set; } - } - - public class UserViewsService : BaseApiService - { - private readonly IUserManager _userManager; - private readonly IUserViewManager _userViewManager; - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - private readonly ILibraryManager _libraryManager; - - public UserViewsService( - ILogger<UserViewsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - IUserViewManager userViewManager, - IDtoService dtoService, - IAuthorizationContext authContext, - ILibraryManager libraryManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _userViewManager = userViewManager; - _dtoService = dtoService; - _authContext = authContext; - _libraryManager = libraryManager; - } - - public object Get(GetUserViews request) - { - var query = new UserViewQuery - { - UserId = request.UserId - }; - - if (request.IncludeExternalContent.HasValue) - { - query.IncludeExternalContent = request.IncludeExternalContent.Value; - } - - query.IncludeHidden = request.IncludeHidden; - - if (!string.IsNullOrWhiteSpace(request.PresetViews)) - { - query.PresetViews = request.PresetViews.Split(','); - } - - var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty; - if (app.IndexOf("emby rt", StringComparison.OrdinalIgnoreCase) != -1) - { - query.PresetViews = new[] { CollectionType.Movies, CollectionType.TvShows }; - } - - var folders = _userViewManager.GetUserViews(query); - - var dtoOptions = GetDtoOptions(_authContext, request); - var fields = dtoOptions.Fields.ToList(); - - fields.Add(ItemFields.PrimaryImageAspectRatio); - fields.Add(ItemFields.DisplayPreferencesId); - fields.Remove(ItemFields.BasicSyncInfo); - dtoOptions.Fields = fields.ToArray(); - - var user = _userManager.GetUserById(request.UserId); - - var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) - .ToArray(); - - var result = new QueryResult<BaseItemDto> - { - Items = dtos, - TotalRecordCount = dtos.Length - }; - - return ToOptimizedResult(result); - } - - public object Get(GetGroupingOptions request) - { - var user = _userManager.GetUserById(request.UserId); - - var list = _libraryManager.GetUserRootFolder() - .GetChildren(user, true) - .OfType<Folder>() - .Where(UserView.IsEligibleForGrouping) - .Select(i => new SpecialViewOption - { - Name = i.Name, - Id = i.Id.ToString("N", CultureInfo.InvariantCulture) - }) - .OrderBy(i => i.Name) - .ToArray(); - - return ToOptimizedResult(list); - } - } - - class SpecialViewOption - { - public string Name { get; set; } - - public string Id { get; set; } - } -} diff --git a/MediaBrowser.Api/UserLibrary/YearsService.cs b/MediaBrowser.Api/UserLibrary/YearsService.cs deleted file mode 100644 index 0523f89fa..000000000 --- a/MediaBrowser.Api/UserLibrary/YearsService.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.UserLibrary -{ - /// <summary> - /// Class GetYears. - /// </summary> - [Route("/Years", "GET", Summary = "Gets all years from a given item, folder, or the entire library")] - public class GetYears : GetItemsByName - { - } - - /// <summary> - /// Class GetYear. - /// </summary> - [Route("/Years/{Year}", "GET", Summary = "Gets a year")] - public class GetYear : IReturn<BaseItemDto> - { - /// <summary> - /// Gets or sets the year. - /// </summary> - /// <value>The year.</value> - [ApiMember(Name = "Year", Description = "The year", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")] - public int Year { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - } - - /// <summary> - /// Class YearsService. - /// </summary> - [Authenticated] - public class YearsService : BaseItemsByNameService<Year> - { - public YearsService( - ILogger<YearsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ILibraryManager libraryManager, - IUserDataManager userDataRepository, - IDtoService dtoService, - IAuthorizationContext authorizationContext) - : base( - logger, - serverConfigurationManager, - httpResultFactory, - userManager, - libraryManager, - userDataRepository, - dtoService, - authorizationContext) - { - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetYear request) - { - var result = GetItem(request); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the item. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{BaseItemDto}.</returns> - private BaseItemDto GetItem(GetYear request) - { - var item = LibraryManager.GetYear(request.Year); - - var dtoOptions = GetDtoOptions(AuthorizationContext, request); - - if (!request.UserId.Equals(Guid.Empty)) - { - var user = UserManager.GetUserById(request.UserId); - - return DtoService.GetBaseItemDto(item, dtoOptions, user); - } - - return DtoService.GetBaseItemDto(item, dtoOptions); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetYears request) - { - var result = GetResult(request); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets all items. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="items">The items.</param> - /// <returns>IEnumerable{Tuple{System.StringFunc{System.Int32}}}.</returns> - protected override IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items) - { - return items - .Select(i => i.ProductionYear ?? 0) - .Where(i => i > 0) - .Distinct() - .Select(year => LibraryManager.GetYear(year)); - } - } -} diff --git a/MediaBrowser.Api/UserService.cs b/MediaBrowser.Api/UserService.cs deleted file mode 100644 index 372ec1692..000000000 --- a/MediaBrowser.Api/UserService.cs +++ /dev/null @@ -1,632 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.Users; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class GetUsers. - /// </summary> - [Route("/Users", "GET", Summary = "Gets a list of users")] - [Authenticated] - public class GetUsers : IReturn<UserDto[]> - { - [ApiMember(Name = "IsHidden", Description = "Optional filter by IsHidden=true or false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsHidden { get; set; } - - [ApiMember(Name = "IsDisabled", Description = "Optional filter by IsDisabled=true or false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsDisabled { get; set; } - - [ApiMember(Name = "IsGuest", Description = "Optional filter by IsGuest=true or false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsGuest { get; set; } - } - - [Route("/Users/Public", "GET", Summary = "Gets a list of publicly visible users for display on a login screen.")] - public class GetPublicUsers : IReturn<UserDto[]> - { - } - - /// <summary> - /// Class GetUser. - /// </summary> - [Route("/Users/{Id}", "GET", Summary = "Gets a user by Id")] - [Authenticated(EscapeParentalControl = true)] - public class GetUser : IReturn<UserDto> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class DeleteUser. - /// </summary> - [Route("/Users/{Id}", "DELETE", Summary = "Deletes a user")] - [Authenticated(Roles = "Admin")] - public class DeleteUser : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class AuthenticateUser. - /// </summary> - [Route("/Users/{Id}/Authenticate", "POST", Summary = "Authenticates a user")] - public class AuthenticateUser : IReturn<AuthenticationResult> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - - [ApiMember(Name = "Pw", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Pw { get; set; } - - /// <summary> - /// Gets or sets the password. - /// </summary> - /// <value>The password.</value> - [ApiMember(Name = "Password", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Password { get; set; } - } - - /// <summary> - /// Class AuthenticateUser. - /// </summary> - [Route("/Users/AuthenticateByName", "POST", Summary = "Authenticates a user")] - public class AuthenticateUserByName : IReturn<AuthenticationResult> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Username", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Username { get; set; } - - /// <summary> - /// Gets or sets the password. - /// </summary> - /// <value>The password.</value> - [ApiMember(Name = "Password", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Password { get; set; } - - [ApiMember(Name = "Pw", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Pw { get; set; } - } - - [Route("/Users/AuthenticateWithQuickConnect", "POST", Summary = "Authenticates a user")] - public class AuthenticateUserQuickConnect : IReturn<AuthenticationResult> - { - /// <summary> - /// Gets or sets the token. - /// </summary> - /// <value>The token</value> - [ApiMember(Name = "Token", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Token { get; set; } - } - - /// <summary> - /// Class UpdateUserPassword. - /// </summary> - [Route("/Users/{Id}/Password", "POST", Summary = "Updates a user's password")] - [Authenticated] - public class UpdateUserPassword : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - public Guid Id { get; set; } - - /// <summary> - /// Gets or sets the password. - /// </summary> - /// <value>The password.</value> - public string CurrentPassword { get; set; } - - public string CurrentPw { get; set; } - - public string NewPw { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [reset password]. - /// </summary> - /// <value><c>true</c> if [reset password]; otherwise, <c>false</c>.</value> - public bool ResetPassword { get; set; } - } - - /// <summary> - /// Class UpdateUserEasyPassword. - /// </summary> - [Route("/Users/{Id}/EasyPassword", "POST", Summary = "Updates a user's easy password")] - [Authenticated] - public class UpdateUserEasyPassword : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - public Guid Id { get; set; } - - /// <summary> - /// Gets or sets the new password. - /// </summary> - /// <value>The new password.</value> - public string NewPassword { get; set; } - - public string NewPw { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [reset password]. - /// </summary> - /// <value><c>true</c> if [reset password]; otherwise, <c>false</c>.</value> - public bool ResetPassword { get; set; } - } - - /// <summary> - /// Class UpdateUser. - /// </summary> - [Route("/Users/{Id}", "POST", Summary = "Updates a user")] - [Authenticated] - public class UpdateUser : UserDto, IReturnVoid - { - } - - /// <summary> - /// Class UpdateUser. - /// </summary> - [Route("/Users/{Id}/Policy", "POST", Summary = "Updates a user policy")] - [Authenticated(Roles = "admin")] - public class UpdateUserPolicy : UserPolicy, IReturnVoid - { - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class UpdateUser. - /// </summary> - [Route("/Users/{Id}/Configuration", "POST", Summary = "Updates a user configuration")] - [Authenticated] - public class UpdateUserConfiguration : UserConfiguration, IReturnVoid - { - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class CreateUser. - /// </summary> - [Route("/Users/New", "POST", Summary = "Creates a user")] - [Authenticated(Roles = "Admin")] - public class CreateUserByName : IReturn<UserDto> - { - [ApiMember(Name = "Name", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Name { get; set; } - - [ApiMember(Name = "Password", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Password { get; set; } - } - - [Route("/Users/ForgotPassword", "POST", Summary = "Initiates the forgot password process for a local user")] - public class ForgotPassword : IReturn<ForgotPasswordResult> - { - [ApiMember(Name = "EnteredUsername", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public string EnteredUsername { get; set; } - } - - [Route("/Users/ForgotPassword/Pin", "POST", Summary = "Redeems a forgot password pin")] - public class ForgotPasswordPin : IReturn<PinRedeemResult> - { - [ApiMember(Name = "Pin", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Pin { get; set; } - } - - /// <summary> - /// Class UsersService. - /// </summary> - public class UserService : BaseApiService - { - /// <summary> - /// The user manager. - /// </summary> - private readonly IUserManager _userManager; - private readonly ISessionManager _sessionMananger; - private readonly INetworkManager _networkManager; - private readonly IDeviceManager _deviceManager; - private readonly IAuthorizationContext _authContext; - - public UserService( - ILogger<UserService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ISessionManager sessionMananger, - INetworkManager networkManager, - IDeviceManager deviceManager, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _sessionMananger = sessionMananger; - _networkManager = networkManager; - _deviceManager = deviceManager; - _authContext = authContext; - } - - public object Get(GetPublicUsers request) - { - // If the startup wizard hasn't been completed then just return all users - if (!ServerConfigurationManager.Configuration.IsStartupWizardCompleted) - { - return Get(new GetUsers - { - IsDisabled = false - }); - } - - return Get(new GetUsers - { - IsHidden = false, - IsDisabled = false - }, true, true); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetUsers request) - { - return Get(request, false, false); - } - - private object Get(GetUsers request, bool filterByDevice, bool filterByNetwork) - { - var users = _userManager.Users; - - if (request.IsDisabled.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == request.IsDisabled.Value); - } - - if (request.IsHidden.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == request.IsHidden.Value); - } - - if (filterByDevice) - { - var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId; - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); - } - } - - if (filterByNetwork) - { - if (!_networkManager.IsInLocalNetwork(Request.RemoteIp)) - { - users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); - } - } - - var result = users - .OrderBy(u => u.Username) - .Select(i => _userManager.GetUserDto(i, Request.RemoteIp)) - .ToArray(); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetUser request) - { - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - var result = _userManager.GetUserDto(user, Request.RemoteIp); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Delete(DeleteUser request) - { - return DeleteAsync(request); - } - - public Task DeleteAsync(DeleteUser request) - { - _userManager.DeleteUser(request.Id); - _sessionMananger.RevokeUserTokens(request.Id, null); - return Task.CompletedTask; - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Post(AuthenticateUser request) - { - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - if (!string.IsNullOrEmpty(request.Password) && string.IsNullOrEmpty(request.Pw)) - { - throw new MethodNotAllowedException("Hashed-only passwords are not valid for this API."); - } - - // Password should always be null - return Post(new AuthenticateUserByName - { - Username = user.Username, - Password = null, - Pw = request.Pw - }); - } - - public async Task<object> Post(AuthenticateUserByName request) - { - var auth = _authContext.GetAuthorizationInfo(Request); - - try - { - var result = await _sessionMananger.AuthenticateNewSession(new AuthenticationRequest - { - App = auth.Client, - AppVersion = auth.Version, - DeviceId = auth.DeviceId, - DeviceName = auth.Device, - Password = request.Pw, - PasswordSha1 = request.Password, - RemoteEndPoint = Request.RemoteIp, - Username = request.Username - }).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{Request.RemoteIp}] {e.Message}", e); - } - } - - public async Task<object> Post(AuthenticateUserQuickConnect request) - { - var auth = _authContext.GetAuthorizationInfo(Request); - - try - { - var result = await _sessionMananger.AuthenticateQuickConnect(new AuthenticationRequest - { - App = auth.Client, - AppVersion = auth.Version, - DeviceId = auth.DeviceId, - DeviceName = auth.Device - }, request.Token).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{Request.RemoteIp}] {e.Message}"); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(UpdateUserPassword request) - { - return PostAsync(request); - } - - public async Task PostAsync(UpdateUserPassword request) - { - AssertCanUpdateUser(_authContext, _userManager, request.Id, true); - - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - if (request.ResetPassword) - { - await _userManager.ResetPassword(user).ConfigureAwait(false); - } - else - { - var success = await _userManager.AuthenticateUser( - user.Username, - request.CurrentPw, - request.CurrentPassword, - Request.RemoteIp, - false).ConfigureAwait(false); - - if (success == null) - { - throw new ArgumentException("Invalid user or password entered."); - } - - await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); - - var currentToken = _authContext.GetAuthorizationInfo(Request).Token; - - _sessionMananger.RevokeUserTokens(user.Id, currentToken); - } - } - - public void Post(UpdateUserEasyPassword request) - { - AssertCanUpdateUser(_authContext, _userManager, request.Id, true); - - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - if (request.ResetPassword) - { - _userManager.ResetEasyPassword(user); - } - else - { - _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public async Task Post(UpdateUser request) - { - var id = Guid.Parse(GetPathValue(1)); - - AssertCanUpdateUser(_authContext, _userManager, id, false); - - var dtoUser = request; - - var user = _userManager.GetUserById(id); - - if (string.Equals(user.Username, dtoUser.Name, StringComparison.Ordinal)) - { - await _userManager.UpdateUserAsync(user); - _userManager.UpdateConfiguration(user.Id, dtoUser.Configuration); - } - else - { - await _userManager.RenameUser(user, dtoUser.Name).ConfigureAwait(false); - - _userManager.UpdateConfiguration(dtoUser.Id, dtoUser.Configuration); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Post(CreateUserByName request) - { - var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false); - - // no need to authenticate password for new user - if (request.Password != null) - { - await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); - } - - var result = _userManager.GetUserDto(newUser, Request.RemoteIp); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(ForgotPassword request) - { - var isLocal = Request.IsLocal || _networkManager.IsInLocalNetwork(Request.RemoteIp); - - var result = await _userManager.StartForgotPasswordProcess(request.EnteredUsername, isLocal).ConfigureAwait(false); - - return result; - } - - public async Task<object> Post(ForgotPasswordPin request) - { - var result = await _userManager.RedeemPasswordResetPin(request.Pin).ConfigureAwait(false); - - return result; - } - - public void Post(UpdateUserConfiguration request) - { - AssertCanUpdateUser(_authContext, _userManager, request.Id, false); - - _userManager.UpdateConfiguration(request.Id, request); - } - - public void Post(UpdateUserPolicy request) - { - var user = _userManager.GetUserById(request.Id); - - // If removing admin access - if (!request.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) - { - if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) - { - throw new ArgumentException("There must be at least one user in the system with administrative access."); - } - } - - // If disabling - if (request.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) - { - throw new ArgumentException("Administrators cannot be disabled."); - } - - // If disabling - if (request.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) - { - if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) - { - throw new ArgumentException("There must be at least one enabled user in the system."); - } - - var currentToken = _authContext.GetAuthorizationInfo(Request).Token; - _sessionMananger.RevokeUserTokens(user.Id, currentToken); - } - - _userManager.UpdatePolicy(request.Id, request); - } - } -} diff --git a/MediaBrowser.Api/VideosService.cs b/MediaBrowser.Api/VideosService.cs deleted file mode 100644 index 957a279f8..000000000 --- a/MediaBrowser.Api/VideosService.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using System.Threading; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Videos/{Id}/AdditionalParts", "GET", Summary = "Gets additional parts for a video.")] - [Authenticated] - public class GetAdditionalParts : IReturn<QueryResult<BaseItemDto>> - { - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Videos/{Id}/AlternateSources", "DELETE", Summary = "Removes alternate video sources.")] - [Authenticated(Roles = "Admin")] - public class DeleteAlternateSources : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/Videos/MergeVersions", "POST", Summary = "Merges videos into a single record")] - [Authenticated(Roles = "Admin")] - public class MergeVersions : IReturnVoid - { - [ApiMember(Name = "Ids", Description = "Item id list. This allows multiple, comma delimited.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)] - public string Ids { get; set; } - } - - public class VideosService : BaseApiService - { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - - public VideosService( - ILogger<VideosService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - _authContext = authContext; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetAdditionalParts request) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var item = string.IsNullOrEmpty(request.Id) - ? (!request.UserId.Equals(Guid.Empty) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.RootFolder) - : _libraryManager.GetItemById(request.Id); - - var dtoOptions = GetDtoOptions(_authContext, request); - - BaseItemDto[] items; - if (item is Video video) - { - items = video.GetAdditionalParts() - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) - .ToArray(); - } - else - { - items = Array.Empty<BaseItemDto>(); - } - - var result = new QueryResult<BaseItemDto> - { - Items = items, - TotalRecordCount = items.Length - }; - - return ToOptimizedResult(result); - } - - public void Delete(DeleteAlternateSources request) - { - var video = (Video)_libraryManager.GetItemById(request.Id); - - foreach (var link in video.GetLinkedAlternateVersions()) - { - link.SetPrimaryVersionId(null); - link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - - link.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - } - - video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - video.SetPrimaryVersionId(null); - video.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - } - - public void Post(MergeVersions request) - { - var items = request.Ids.Split(',') - .Select(i => _libraryManager.GetItemById(i)) - .OfType<Video>() - .OrderBy(i => i.Id) - .ToList(); - - if (items.Count < 2) - { - throw new ArgumentException("Please supply at least two videos to merge."); - } - - var videosWithVersions = items.Where(i => i.MediaSourceCount > 1) - .ToList(); - - var primaryVersion = videosWithVersions.FirstOrDefault(); - if (primaryVersion == null) - { - primaryVersion = items.OrderBy(i => - { - if (i.Video3DFormat.HasValue || i.VideoType != Model.Entities.VideoType.VideoFile) - { - return 1; - } - - return 0; - }) - .ThenByDescending(i => - { - return i.GetDefaultVideoStream()?.Width ?? 0; - }).First(); - } - - var list = primaryVersion.LinkedAlternateVersions.ToList(); - - foreach (var item in items.Where(i => i.Id != primaryVersion.Id)) - { - item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); - - item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - - list.Add(new LinkedChild - { - Path = item.Path, - ItemId = item.Id - }); - - foreach (var linkedItem in item.LinkedAlternateVersions) - { - if (!list.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) - { - list.Add(linkedItem); - } - } - - if (item.LinkedAlternateVersions.Length > 0) - { - item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - } - } - - primaryVersion.LinkedAlternateVersions = list.ToArray(); - primaryVersion.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - } - } -} diff --git a/MediaBrowser.Common/Json/Converters/JsonDoubleConverter.cs b/MediaBrowser.Common/Json/Converters/JsonDoubleConverter.cs new file mode 100644 index 000000000..56c0ecbe9 --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonDoubleConverter.cs @@ -0,0 +1,56 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Double to String JSON converter. + /// Web client send quoted doubles. + /// </summary> + public class JsonDoubleConverter : JsonConverter<double> + { + /// <summary> + /// Read JSON string as double. + /// </summary> + /// <param name="reader"><see cref="Utf8JsonReader"/>.</param> + /// <param name="typeToConvert">Type.</param> + /// <param name="options">Options.</param> + /// <returns>Parsed value.</returns> + public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + // try to parse number directly from bytes + var span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; + if (Utf8Parser.TryParse(span, out double number, out var bytesConsumed) && span.Length == bytesConsumed) + { + return number; + } + + // try to parse from a string if the above failed, this covers cases with other escaped/UTF characters + if (double.TryParse(reader.GetString(), out number)) + { + return number; + } + } + + // fallback to default handling + return reader.GetDouble(); + } + + /// <summary> + /// Write double to JSON string. + /// </summary> + /// <param name="writer"><see cref="Utf8JsonWriter"/>.</param> + /// <param name="value">Value to write.</param> + /// <param name="options">Options.</param> + public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value); + } + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs b/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs index fe5dd6cd4..70c375b8c 100644 --- a/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs +++ b/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs @@ -14,40 +14,27 @@ namespace MediaBrowser.Common.Json.Converters /// <inheritdoc /> public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - static void ThrowFormatException() => throw new FormatException("Invalid format for an integer."); - ReadOnlySpan<byte> span = stackalloc byte[0]; - - if (reader.HasValueSequence) - { - long sequenceLength = reader.ValueSequence.Length; - Span<byte> stackSpan = stackalloc byte[(int)sequenceLength]; - reader.ValueSequence.CopyTo(stackSpan); - span = stackSpan; - } - else + if (reader.TokenType == JsonTokenType.String) { - span = reader.ValueSpan; - } + ReadOnlySpan<byte> span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; + if (Utf8Parser.TryParse(span, out int number, out int bytesConsumed) && span.Length == bytesConsumed) + { + return number; + } - if (!Utf8Parser.TryParse(span, out int number, out _)) - { - ThrowFormatException(); + if (int.TryParse(reader.GetString(), out number)) + { + return number; + } } - return number; + return reader.GetInt32(); } /// <inheritdoc /> public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) { - static void ThrowInvalidOperationException() => throw new InvalidOperationException(); - Span<byte> span = stackalloc byte[16]; - if (Utf8Formatter.TryFormat(value, span, out int bytesWritten)) - { - writer.WriteStringValue(span.Slice(0, bytesWritten)); - } - - ThrowInvalidOperationException(); + writer.WriteNumberValue(value); } } } diff --git a/MediaBrowser.Common/Json/Converters/JsonInt64Converter.cs b/MediaBrowser.Common/Json/Converters/JsonInt64Converter.cs new file mode 100644 index 000000000..d18fd95d5 --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonInt64Converter.cs @@ -0,0 +1,56 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Long to String JSON converter. + /// Javascript does not support 64-bit integers. + /// </summary> + public class JsonInt64Converter : JsonConverter<long> + { + /// <summary> + /// Read JSON string as int64. + /// </summary> + /// <param name="reader"><see cref="Utf8JsonReader"/>.</param> + /// <param name="type">Type.</param> + /// <param name="options">Options.</param> + /// <returns>Parsed value.</returns> + public override long Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + // try to parse number directly from bytes + var span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; + if (Utf8Parser.TryParse(span, out long number, out var bytesConsumed) && span.Length == bytesConsumed) + { + return number; + } + + // try to parse from a string if the above failed, this covers cases with other escaped/UTF characters + if (long.TryParse(reader.GetString(), out number)) + { + return number; + } + } + + // fallback to default handling + return reader.GetInt64(); + } + + /// <summary> + /// Write long to JSON string. + /// </summary> + /// <param name="writer"><see cref="Utf8JsonWriter"/>.</param> + /// <param name="value">Value to write.</param> + /// <param name="options">Options.</param> + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(NumberFormatInfo.InvariantInfo)); + } + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs index 0a36e1cb2..8053461f0 100644 --- a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs +++ b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs @@ -25,7 +25,7 @@ namespace MediaBrowser.Common.Json.Converters /// <param name="typeToConvert">The type to convert.</param> /// <param name="options">The json serializer options.</param> /// <returns>Typed dictionary.</returns> - /// <exception cref="NotSupportedException">Not supported.</exception> + /// <exception cref="NotSupportedException">Dictionary key type not supported.</exception> public override IDictionary<TKey, TValue> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var convertedType = typeof(Dictionary<,>).MakeGenericType(typeof(string), typeToConvert.GenericTypeArguments[1]); @@ -72,7 +72,7 @@ namespace MediaBrowser.Common.Json.Converters { if (k != null) { - convertedDictionary[k.ToString()] = v; + convertedDictionary[k.ToString()] = v; } } diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index 78a458add..36ab6d900 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -12,20 +12,51 @@ namespace MediaBrowser.Common.Json /// <summary> /// Gets the default <see cref="JsonSerializerOptions" /> options. /// </summary> + /// <remarks> + /// When changing these options, update + /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs + /// -> AddJellyfinApi + /// -> AddJsonOptions. + /// </remarks> /// <returns>The default <see cref="JsonSerializerOptions" /> options.</returns> public static JsonSerializerOptions GetOptions() { - var options = new JsonSerializerOptions() + var options = new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Disallow, WriteIndented = false }; options.Converters.Add(new JsonGuidConverter()); + options.Converters.Add(new JsonInt32Converter()); options.Converters.Add(new JsonStringEnumConverter()); options.Converters.Add(new JsonNonStringKeyDictionaryConverterFactory()); + options.Converters.Add(new JsonInt64Converter()); + options.Converters.Add(new JsonDoubleConverter()); return options; } + + /// <summary> + /// Gets camelCase json options. + /// </summary> + /// <returns>The camelCase <see cref="JsonSerializerOptions" /> options.</returns> + public static JsonSerializerOptions GetCamelCaseOptions() + { + var options = GetOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + return options; + } + + /// <summary> + /// Gets PascalCase json options. + /// </summary> + /// <returns>The PascalCase <see cref="JsonSerializerOptions" /> options.</returns> + public static JsonSerializerOptions GetPascalCaseOptions() + { + var options = GetOptions(); + options.PropertyNamingPolicy = null; + return options; + } } } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index cb35d6e32..22bb7fd55 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs new file mode 100644 index 000000000..b6bfed3e5 --- /dev/null +++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Entities; + +namespace MediaBrowser.Controller +{ + /// <summary> + /// Manages the storage and retrieval of display preferences. + /// </summary> + public interface IDisplayPreferencesManager + { + /// <summary> + /// Gets the display preferences for the user and client. + /// </summary> + /// <param name="userId">The user's id.</param> + /// <param name="client">The client string.</param> + /// <returns>The associated display preferences.</returns> + DisplayPreferences GetDisplayPreferences(Guid userId, string client); + + /// <summary> + /// Gets the default item display preferences for the user and client. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="client">The client string.</param> + /// <returns>The item display preferences.</returns> + ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client); + + /// <summary> + /// Gets all of the item display preferences for the user and client. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="client">The client string.</param> + /// <returns>A list of item display preferences.</returns> + IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client); + + /// <summary> + /// Saves changes to the provided display preferences. + /// </summary> + /// <param name="preferences">The display preferences to save.</param> + void SaveChanges(DisplayPreferences preferences); + + /// <summary> + /// Saves changes to the provided item display preferences. + /// </summary> + /// <param name="preferences">The item display preferences to save.</param> + void SaveChanges(ItemDisplayPreferences preferences); + } +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index bb56c83c2..9abcf2b62 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index c20c9c2c4..2dd21be3c 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -456,6 +456,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isQsvEncoder = outputVideoCodec.IndexOf("qsv", StringComparison.OrdinalIgnoreCase) != -1; var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + var isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); if (!IsCopyCodec(outputVideoCodec)) { @@ -511,6 +512,12 @@ namespace MediaBrowser.Controller.MediaEncoding } } } + + if (state.IsVideoRequest + && string.Equals(encodingOptions.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + arg.Append("-hwaccel videotoolbox "); + } } arg.Append("-i ") @@ -1323,6 +1330,17 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } + public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream) + { + if (audioBitRate.HasValue) + { + // Don't encode any higher than this + return Math.Min(384000, audioBitRate.Value); + } + + return null; + } + public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions, bool isHls) { var channels = state.OutputAudioChannels; @@ -1599,42 +1617,48 @@ namespace MediaBrowser.Controller.MediaEncoding { outputSizeParam = GetOutputSizeParam(state, options, outputVideoCodec).TrimEnd('"'); - var index = outputSizeParam.IndexOf("hwdownload", StringComparison.OrdinalIgnoreCase); + // hwupload=extra_hw_frames=64,vpp_qsv (for overlay_qsv on linux) + var index = outputSizeParam.IndexOf("hwupload=extra_hw_frames", StringComparison.OrdinalIgnoreCase); if (index != -1) { outputSizeParam = outputSizeParam.Slice(index); } else { - index = outputSizeParam.IndexOf("hwupload=extra_hw_frames", StringComparison.OrdinalIgnoreCase); + // vpp_qsv + index = outputSizeParam.IndexOf("vpp", StringComparison.OrdinalIgnoreCase); if (index != -1) { outputSizeParam = outputSizeParam.Slice(index); } else { - index = outputSizeParam.IndexOf("format", StringComparison.OrdinalIgnoreCase); + // hwdownload,format=p010le (hardware decode + software encode for vaapi) + index = outputSizeParam.IndexOf("hwdownload", StringComparison.OrdinalIgnoreCase); if (index != -1) { outputSizeParam = outputSizeParam.Slice(index); } else { - index = outputSizeParam.IndexOf("yadif", StringComparison.OrdinalIgnoreCase); + // format=nv12|vaapi,hwupload,scale_vaapi + index = outputSizeParam.IndexOf("format", StringComparison.OrdinalIgnoreCase); if (index != -1) { outputSizeParam = outputSizeParam.Slice(index); } else { - index = outputSizeParam.IndexOf("scale", StringComparison.OrdinalIgnoreCase); + // yadif,scale=expr + index = outputSizeParam.IndexOf("yadif", StringComparison.OrdinalIgnoreCase); if (index != -1) { outputSizeParam = outputSizeParam.Slice(index); } else { - index = outputSizeParam.IndexOf("vpp", StringComparison.OrdinalIgnoreCase); + // scale=expr + index = outputSizeParam.IndexOf("scale", StringComparison.OrdinalIgnoreCase); if (index != -1) { outputSizeParam = outputSizeParam.Slice(index); diff --git a/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs b/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs index ad786f97b..87a7f7e10 100644 --- a/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs +++ b/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs @@ -52,6 +52,8 @@ namespace MediaBrowser.Controller.Net return (Roles ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); } + public bool IgnoreLegacyAuth { get; set; } + public bool AllowLocalOnly { get; set; } } @@ -66,5 +68,7 @@ namespace MediaBrowser.Controller.Net bool AllowLocalOnly { get; } string[] GetRoles(); + + bool IgnoreLegacyAuth { get; } } } diff --git a/MediaBrowser.Controller/Net/IAuthService.cs b/MediaBrowser.Controller/Net/IAuthService.cs index 56737dc65..2055a656a 100644 --- a/MediaBrowser.Controller/Net/IAuthService.cs +++ b/MediaBrowser.Controller/Net/IAuthService.cs @@ -6,11 +6,25 @@ using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net { + /// <summary> + /// IAuthService. + /// </summary> public interface IAuthService { - void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues); + /// <summary> + /// Authenticate and authorize request. + /// </summary> + /// <param name="request">Request.</param> + /// <param name="authAttribtutes">Authorization attributes.</param> + void Authenticate(IRequest request, IAuthenticationAttributes authAttribtutes); - User? Authenticate(HttpRequest request, IAuthenticationAttributes authAttribtues); + /// <summary> + /// Authenticate and authorize request. + /// </summary> + /// <param name="request">Request.</param> + /// <param name="authAttribtutes">Authorization attributes.</param> + /// <returns>Authenticated user.</returns> + User? Authenticate(HttpRequest request, IAuthenticationAttributes authAttribtutes); /// <summary> /// Authenticate request. diff --git a/MediaBrowser.Controller/Persistence/IDisplayPreferencesRepository.cs b/MediaBrowser.Controller/Persistence/IDisplayPreferencesRepository.cs deleted file mode 100644 index c2dcb66d7..000000000 --- a/MediaBrowser.Controller/Persistence/IDisplayPreferencesRepository.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using MediaBrowser.Model.Entities; - -namespace MediaBrowser.Controller.Persistence -{ - /// <summary> - /// Interface IDisplayPreferencesRepository. - /// </summary> - public interface IDisplayPreferencesRepository : IRepository - { - /// <summary> - /// Saves display preferences for an item. - /// </summary> - /// <param name="displayPreferences">The display preferences.</param> - /// <param name="userId">The user id.</param> - /// <param name="client">The client.</param> - /// <param name="cancellationToken">The cancellation token.</param> - void SaveDisplayPreferences( - DisplayPreferences displayPreferences, - string userId, - string client, - CancellationToken cancellationToken); - - /// <summary> - /// Saves all display preferences for a user. - /// </summary> - /// <param name="displayPreferences">The display preferences.</param> - /// <param name="userId">The user id.</param> - /// <param name="cancellationToken">The cancellation token.</param> - void SaveAllDisplayPreferences( - IEnumerable<DisplayPreferences> displayPreferences, - Guid userId, - CancellationToken cancellationToken); - - /// <summary> - /// Gets the display preferences. - /// </summary> - /// <param name="displayPreferencesId">The display preferences id.</param> - /// <param name="userId">The user id.</param> - /// <param name="client">The client.</param> - /// <returns>Task{DisplayPreferences}.</returns> - DisplayPreferences GetDisplayPreferences(string displayPreferencesId, string userId, string client); - - /// <summary> - /// Gets all display preferences for the given user. - /// </summary> - /// <param name="userId">The user id.</param> - /// <returns>Task{DisplayPreferences}.</returns> - IEnumerable<DisplayPreferences> GetAllDisplayPreferences(Guid userId); - } -} diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index b1a638883..0fd63770f 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -6,6 +6,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index f02999370..a8ebe6bc5 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Concurrent; using System.Diagnostics; diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs index e040286ab..4a54b677d 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.Linq; using BDInfo.IO; @@ -5,7 +7,7 @@ using MediaBrowser.Model.IO; namespace MediaBrowser.MediaEncoding.BdInfo { - class BdInfoDirectoryInfo : IDirectoryInfo + public class BdInfoDirectoryInfo : IDirectoryInfo { private readonly IFileSystem _fileSystem = null; @@ -43,25 +45,32 @@ namespace MediaBrowser.MediaEncoding.BdInfo public IDirectoryInfo[] GetDirectories() { - return Array.ConvertAll(_fileSystem.GetDirectories(_impl.FullName).ToArray(), + return Array.ConvertAll( + _fileSystem.GetDirectories(_impl.FullName).ToArray(), x => new BdInfoDirectoryInfo(_fileSystem, x)); } public IFileInfo[] GetFiles() { - return Array.ConvertAll(_fileSystem.GetFiles(_impl.FullName).ToArray(), + return Array.ConvertAll( + _fileSystem.GetFiles(_impl.FullName).ToArray(), x => new BdInfoFileInfo(x)); } public IFileInfo[] GetFiles(string searchPattern) { - return Array.ConvertAll(_fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, false).ToArray(), + return Array.ConvertAll( + _fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, false).ToArray(), x => new BdInfoFileInfo(x)); } public IFileInfo[] GetFiles(string searchPattern, System.IO.SearchOption searchOption) { - return Array.ConvertAll(_fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, + return Array.ConvertAll( + _fileSystem.GetFiles( + _impl.FullName, + new[] { searchPattern }, + false, searchOption.HasFlag(System.IO.SearchOption.AllDirectories)).ToArray(), x => new BdInfoFileInfo(x)); } diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs index ccfae2fa5..9108d9649 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs @@ -15,6 +15,10 @@ namespace MediaBrowser.MediaEncoding.BdInfo { private readonly IFileSystem _fileSystem; + /// <summary> + /// Initializes a new instance of the <see cref="BdInfoExaminer" /> class. + /// </summary> + /// <param name="fileSystem">The filesystem.</param> public BdInfoExaminer(IFileSystem fileSystem) { _fileSystem = fileSystem; diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs index a6ff4f767..0a8af8e9c 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs @@ -1,11 +1,18 @@ +#pragma warning disable CS1591 + using System.IO; using MediaBrowser.Model.IO; namespace MediaBrowser.MediaEncoding.BdInfo { - class BdInfoFileInfo : BDInfo.IO.IFileInfo + public class BdInfoFileInfo : BDInfo.IO.IFileInfo { - FileSystemMetadata _impl = null; + private FileSystemMetadata _impl = null; + + public BdInfoFileInfo(FileSystemMetadata impl) + { + _impl = impl; + } public string Name => _impl.Name; @@ -17,14 +24,10 @@ namespace MediaBrowser.MediaEncoding.BdInfo public bool IsDir => _impl.IsDirectory; - public BdInfoFileInfo(FileSystemMetadata impl) - { - _impl = impl; - } - public System.IO.Stream OpenRead() { - return new FileStream(FullName, + return new FileStream( + FullName, FileMode.Open, FileAccess.Read, FileShare.Read); diff --git a/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationFactory.cs b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationFactory.cs index 75534b5bd..f81a337db 100644 --- a/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationFactory.cs +++ b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationFactory.cs @@ -1,9 +1,7 @@ -using System; +#pragma warning disable CS1591 + using System.Collections.Generic; -using System.Globalization; -using System.IO; using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Configuration; namespace MediaBrowser.MediaEncoding.Configuration { @@ -17,32 +15,4 @@ namespace MediaBrowser.MediaEncoding.Configuration }; } } - - public class EncodingConfigurationStore : ConfigurationStore, IValidatingConfiguration - { - public EncodingConfigurationStore() - { - ConfigurationType = typeof(EncodingOptions); - Key = "encoding"; - } - - public void Validate(object oldConfig, object newConfig) - { - var newPath = ((EncodingOptions)newConfig).TranscodingTempPath; - - if (!string.IsNullOrWhiteSpace(newPath) - && !string.Equals(((EncodingOptions)oldConfig).TranscodingTempPath, newPath, StringComparison.Ordinal)) - { - // Validate - if (!Directory.Exists(newPath)) - { - throw new DirectoryNotFoundException( - string.Format( - CultureInfo.InvariantCulture, - "{0} does not exist.", - newPath)); - } - } - } - } } diff --git a/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs new file mode 100644 index 000000000..2f158157e --- /dev/null +++ b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs @@ -0,0 +1,38 @@ +#pragma warning disable CS1591 + +using System; +using System.Globalization; +using System.IO; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.MediaEncoding.Configuration +{ + public class EncodingConfigurationStore : ConfigurationStore, IValidatingConfiguration + { + public EncodingConfigurationStore() + { + ConfigurationType = typeof(EncodingOptions); + Key = "encoding"; + } + + public void Validate(object oldConfig, object newConfig) + { + var newPath = ((EncodingOptions)newConfig).TranscodingTempPath; + + if (!string.IsNullOrWhiteSpace(newPath) + && !string.Equals(((EncodingOptions)oldConfig).TranscodingTempPath, newPath, StringComparison.Ordinal)) + { + // Validate + if (!Directory.Exists(newPath)) + { + throw new DirectoryNotFoundException( + string.Format( + CultureInfo.InvariantCulture, + "{0} does not exist.", + newPath)); + } + } + } + } +} diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 5c43fdcfa..75123a843 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Diagnostics; @@ -12,7 +14,7 @@ namespace MediaBrowser.MediaEncoding.Encoder { private const string DefaultEncoderPath = "ffmpeg"; - private static readonly string[] requiredDecoders = new[] + private static readonly string[] _requiredDecoders = new[] { "h264", "hevc", @@ -55,7 +57,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "vc1_opencl" }; - private static readonly string[] requiredEncoders = new[] + private static readonly string[] _requiredEncoders = new[] { "libx264", "libx265", @@ -110,6 +112,12 @@ namespace MediaBrowser.MediaEncoding.Encoder _encoderPath = encoderPath; } + private enum Codec + { + Encoder, + Decoder + } + public static Version MinVersion { get; } = new Version(4, 0); public static Version MaxVersion { get; } = null; @@ -193,8 +201,8 @@ namespace MediaBrowser.MediaEncoding.Encoder /// If that fails then we use one of the main libraries to determine if it's new/older than the latest /// we have stored. /// </summary> - /// <param name="output"></param> - /// <returns></returns> + /// <param name="output">The output from "ffmpeg -version".</param> + /// <returns>The FFmpeg version.</returns> internal static Version GetFFmpegVersion(string output) { // For pre-built binaries the FFmpeg version should be mentioned at the very start of the output @@ -216,10 +224,10 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <summary> /// Grabs the library names and major.minor version numbers from the 'ffmpeg -version' output - /// and condenses them on to one line. Output format is "name1=major.minor,name2=major.minor,etc." + /// and condenses them on to one line. Output format is "name1=major.minor,name2=major.minor,etc.". /// </summary> - /// <param name="output"></param> - /// <returns></returns> + /// <param name="output">The 'ffmpeg -version' output.</param> + /// <returns>The library names and major.minor version numbers.</returns> private static string GetLibrariesVersionString(string output) { var rc = new StringBuilder(144); @@ -239,12 +247,6 @@ namespace MediaBrowser.MediaEncoding.Encoder return rc.Length == 0 ? null : rc.ToString(); } - private enum Codec - { - Encoder, - Decoder - } - private IEnumerable<string> GetHwaccelTypes() { string output = null; @@ -262,7 +264,7 @@ namespace MediaBrowser.MediaEncoding.Encoder return Enumerable.Empty<string>(); } - var found = output.Split(new char[] {'\r','\n'}, StringSplitOptions.RemoveEmptyEntries).Skip(1).Distinct().ToList(); + var found = output.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Skip(1).Distinct().ToList(); _logger.LogInformation("Available hwaccel types: {Types}", found); return found; @@ -286,7 +288,7 @@ namespace MediaBrowser.MediaEncoding.Encoder return Enumerable.Empty<string>(); } - var required = codec == Codec.Encoder ? requiredEncoders : requiredDecoders; + var required = codec == Codec.Encoder ? _requiredEncoders : _requiredDecoders; var found = Regex .Matches(output, @"^\s\S{6}\s(?<codec>[\w|-]+)\s+.+$", RegexOptions.Multiline) diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs index d4aede572..7c2d9f1fd 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System.Collections.Generic; using System.Linq; using MediaBrowser.Model.MediaInfo; diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 62fdbc618..778c0b18c 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -1,5 +1,8 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -19,9 +22,8 @@ using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.System; -using Microsoft.Extensions.Logging; -using System.Diagnostics; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Encoder { @@ -35,6 +37,11 @@ namespace MediaBrowser.MediaEncoding.Encoder /// </summary> internal const int DefaultImageExtractionTimeout = 5000; + /// <summary> + /// The us culture. + /// </summary> + private readonly CultureInfo _usCulture = new CultureInfo("en-US"); + private readonly ILogger<MediaEncoder> _logger; private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; @@ -47,6 +54,10 @@ namespace MediaBrowser.MediaEncoding.Encoder private readonly object _runningProcessesLock = new object(); private readonly List<ProcessWrapper> _runningProcesses = new List<ProcessWrapper>(); + private List<string> _encoders = new List<string>(); + private List<string> _decoders = new List<string>(); + private List<string> _hwaccels = new List<string>(); + private string _ffmpegPath = string.Empty; private string _ffprobePath; @@ -77,7 +88,7 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <summary> /// Run at startup or if the user removes a Custom path from transcode page. /// Sets global variables FFmpegPath. - /// Precedence is: Config > CLI > $PATH + /// Precedence is: Config > CLI > $PATH. /// </summary> public void SetFFmpegPath() { @@ -122,8 +133,8 @@ namespace MediaBrowser.MediaEncoding.Encoder /// Triggered from the Settings > Transcoding UI page when users submits Custom FFmpeg path to use. /// Only write the new path to xml if it exists. Do not perform validation checks on ffmpeg here. /// </summary> - /// <param name="path"></param> - /// <param name="pathType"></param> + /// <param name="path">The path.</param> + /// <param name="pathType">The path type.</param> public void UpdateEncoderPath(string path, string pathType) { string newPath; @@ -168,8 +179,8 @@ namespace MediaBrowser.MediaEncoding.Encoder /// If checks pass, global variable FFmpegPath and EncoderLocation are updated. /// </summary> /// <param name="path">FQPN to test.</param> - /// <param name="location">Location (External, Custom, System) of tool</param> - /// <returns></returns> + /// <param name="location">Location (External, Custom, System) of tool.</param> + /// <returns><c>true</c> if the version validation succeeded; otherwise, <c>false</c>.</returns> private bool ValidatePath(string path, FFmpegLocation location) { bool rc = false; @@ -221,8 +232,8 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <summary> /// Search the system $PATH environment variable looking for given filename. /// </summary> - /// <param name="fileName"></param> - /// <returns></returns> + /// <param name="fileName">The filename.</param> + /// <returns>The full path to the file.</returns> private string ExistsOnSystemPath(string fileName) { var inJellyfinPath = GetEncoderPathFromDirectory(AppContext.BaseDirectory, fileName, recursive: true); @@ -246,25 +257,19 @@ namespace MediaBrowser.MediaEncoding.Encoder return null; } - private List<string> _encoders = new List<string>(); public void SetAvailableEncoders(IEnumerable<string> list) { _encoders = list.ToList(); - // _logger.Info("Supported encoders: {0}", string.Join(",", list.ToArray())); } - private List<string> _decoders = new List<string>(); public void SetAvailableDecoders(IEnumerable<string> list) { _decoders = list.ToList(); - // _logger.Info("Supported decoders: {0}", string.Join(",", list.ToArray())); } - private List<string> _hwaccels = new List<string>(); public void SetAvailableHwaccels(IEnumerable<string> list) { _hwaccels = list.ToList(); - //_logger.Info("Supported hwaccels: {0}", string.Join(",", list.ToArray())); } public bool SupportsEncoder(string encoder) @@ -332,8 +337,16 @@ namespace MediaBrowser.MediaEncoding.Encoder var forceEnableLogging = request.MediaSource.Protocol != MediaProtocol.File; - return GetMediaInfoInternal(GetInputArgument(inputFiles, request.MediaSource.Protocol), request.MediaSource.Path, request.MediaSource.Protocol, extractChapters, - probeSize, request.MediaType == DlnaProfileType.Audio, request.MediaSource.VideoType, forceEnableLogging, cancellationToken); + return GetMediaInfoInternal( + GetInputArgument(inputFiles, request.MediaSource.Protocol), + request.MediaSource.Path, + request.MediaSource.Protocol, + extractChapters, + probeSize, + request.MediaType == DlnaProfileType.Audio, + request.MediaSource.VideoType, + forceEnableLogging, + cancellationToken); } /// <summary> @@ -342,7 +355,7 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <param name="inputFiles">The input files.</param> /// <param name="protocol">The protocol.</param> /// <returns>System.String.</returns> - /// <exception cref="ArgumentException">Unrecognized InputType</exception> + /// <exception cref="ArgumentException">Unrecognized InputType.</exception> public string GetInputArgument(IReadOnlyList<string> inputFiles, MediaProtocol protocol) => EncodingUtils.GetInputArgument(inputFiles, protocol); @@ -350,7 +363,8 @@ namespace MediaBrowser.MediaEncoding.Encoder /// Gets the media info internal. /// </summary> /// <returns>Task{MediaInfoResult}.</returns> - private async Task<MediaInfo> GetMediaInfoInternal(string inputPath, + private async Task<MediaInfo> GetMediaInfoInternal( + string inputPath, string primaryPath, MediaProtocol protocol, bool extractChapters, @@ -378,7 +392,6 @@ namespace MediaBrowser.MediaEncoding.Encoder FileName = _ffprobePath, Arguments = args, - WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false, }, @@ -439,11 +452,6 @@ namespace MediaBrowser.MediaEncoding.Encoder } } - /// <summary> - /// The us culture. - /// </summary> - protected readonly CultureInfo UsCulture = new CultureInfo("en-US"); - public Task<string> ExtractAudioImage(string path, int? imageStreamIndex, CancellationToken cancellationToken) { return ExtractImage(new[] { path }, null, null, imageStreamIndex, MediaProtocol.File, true, null, null, cancellationToken); @@ -459,8 +467,16 @@ namespace MediaBrowser.MediaEncoding.Encoder return ExtractImage(inputFiles, container, imageStream, imageStreamIndex, protocol, false, null, null, cancellationToken); } - private async Task<string> ExtractImage(string[] inputFiles, string container, MediaStream videoStream, int? imageStreamIndex, MediaProtocol protocol, bool isAudio, - Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken) + private async Task<string> ExtractImage( + string[] inputFiles, + string container, + MediaStream videoStream, + int? imageStreamIndex, + MediaProtocol protocol, + bool isAudio, + Video3DFormat? threedFormat, + TimeSpan? offset, + CancellationToken cancellationToken) { var inputArgument = GetInputArgument(inputFiles, protocol); @@ -645,7 +661,7 @@ namespace MediaBrowser.MediaEncoding.Encoder public string GetTimeParameter(TimeSpan time) { - return time.ToString(@"hh\:mm\:ss\.fff", UsCulture); + return time.ToString(@"hh\:mm\:ss\.fff", _usCulture); } public async Task ExtractVideoImagesOnInterval( @@ -662,11 +678,11 @@ namespace MediaBrowser.MediaEncoding.Encoder { var inputArgument = GetInputArgument(inputFiles, protocol); - var vf = "fps=fps=1/" + interval.TotalSeconds.ToString(UsCulture); + var vf = "fps=fps=1/" + interval.TotalSeconds.ToString(_usCulture); if (maxWidth.HasValue) { - var maxWidthParam = maxWidth.Value.ToString(UsCulture); + var maxWidthParam = maxWidth.Value.ToString(_usCulture); vf += string.Format(",scale=min(iw\\,{0}):trunc(ow/dar/2)*2", maxWidthParam); } @@ -859,6 +875,7 @@ namespace MediaBrowser.MediaEncoding.Encoder if (dispose) { StopProcesses(); + _thumbnailResourcePool.Dispose(); } } diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index aeb4dbe73..017f917e2 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -9,6 +9,7 @@ <TargetFramework>netstandard2.1</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <ItemGroup> @@ -27,4 +28,16 @@ <PackageReference Include="UTF.Unknown" Version="2.3.0" /> </ItemGroup> + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + + <!-- Code Analyzers--> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <!-- <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> --> + <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + </ItemGroup> + </Project> diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs index 3aa296f7f..b2d4db894 100644 --- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs +++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs @@ -3,6 +3,9 @@ using System.Collections.Generic; namespace MediaBrowser.MediaEncoding.Probing { + /// <summary> + /// Class containing helper methods for working with FFprobe output. + /// </summary> public static class FFProbeHelpers { /// <summary> diff --git a/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs b/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs index 6a45ccf49..de062d06b 100644 --- a/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs +++ b/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System.Collections.Generic; using System.Text.Json.Serialization; diff --git a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs index a2ea0766a..8996d3b09 100644 --- a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs +++ b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs @@ -269,6 +269,10 @@ namespace MediaBrowser.MediaEncoding.Probing [JsonPropertyName("loro_surmixlev")] public string LoroSurmixlev { get; set; } + /// <summary> + /// Gets or sets the field_order. + /// </summary> + /// <value>The field_order.</value> [JsonPropertyName("field_order")] public string FieldOrder { get; set; } diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index c85d963ed..19e3bd8e6 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Globalization; @@ -16,10 +18,19 @@ namespace MediaBrowser.MediaEncoding.Probing { public class ProbeResultNormalizer { + // When extracting subtitles, the maximum length to consider (to avoid invalid filenames) + private const int MaxSubtitleDescriptionExtractionLength = 100; + + private const string ArtistReplaceValue = " | "; + + private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; + private readonly CultureInfo _usCulture = new CultureInfo("en-US"); private readonly ILogger _logger; private readonly ILocalizationManager _localization; + private List<string> _splitWhiteList = null; + public ProbeResultNormalizer(ILogger logger, ILocalizationManager localization) { _logger = logger; @@ -368,7 +379,6 @@ namespace MediaBrowser.MediaEncoding.Probing private List<NameValuePair> ReadValueArray(XmlReader reader) { - var pairs = new List<NameValuePair>(); reader.MoveToContent(); @@ -949,50 +959,46 @@ namespace MediaBrowser.MediaEncoding.Probing private void SetAudioInfoFromTags(MediaInfo audio, Dictionary<string, string> tags) { + var peoples = new List<BaseItemPerson>(); var composer = FFProbeHelpers.GetDictionaryValue(tags, "composer"); if (!string.IsNullOrWhiteSpace(composer)) { - var peoples = new List<BaseItemPerson>(); foreach (var person in Split(composer, false)) { peoples.Add(new BaseItemPerson { Name = person, Type = PersonType.Composer }); } - - audio.People = peoples.ToArray(); } - // var conductor = FFProbeHelpers.GetDictionaryValue(tags, "conductor"); - // if (!string.IsNullOrWhiteSpace(conductor)) - //{ - // foreach (var person in Split(conductor, false)) - // { - // audio.People.Add(new BaseItemPerson { Name = person, Type = PersonType.Conductor }); - // } - //} + var conductor = FFProbeHelpers.GetDictionaryValue(tags, "conductor"); + if (!string.IsNullOrWhiteSpace(conductor)) + { + foreach (var person in Split(conductor, false)) + { + peoples.Add(new BaseItemPerson { Name = person, Type = PersonType.Conductor }); + } + } - // var lyricist = FFProbeHelpers.GetDictionaryValue(tags, "lyricist"); - // if (!string.IsNullOrWhiteSpace(lyricist)) - //{ - // foreach (var person in Split(lyricist, false)) - // { - // audio.People.Add(new BaseItemPerson { Name = person, Type = PersonType.Lyricist }); - // } - //} + var lyricist = FFProbeHelpers.GetDictionaryValue(tags, "lyricist"); + if (!string.IsNullOrWhiteSpace(lyricist)) + { + foreach (var person in Split(lyricist, false)) + { + peoples.Add(new BaseItemPerson { Name = person, Type = PersonType.Lyricist }); + } + } // Check for writer some music is tagged that way as alternative to composer/lyricist var writer = FFProbeHelpers.GetDictionaryValue(tags, "writer"); if (!string.IsNullOrWhiteSpace(writer)) { - var peoples = new List<BaseItemPerson>(); foreach (var person in Split(writer, false)) { peoples.Add(new BaseItemPerson { Name = person, Type = PersonType.Writer }); } - - audio.People = peoples.ToArray(); } + audio.People = peoples.ToArray(); audio.Album = FFProbeHelpers.GetDictionaryValue(tags, "album"); var artists = FFProbeHelpers.GetDictionaryValue(tags, "artists"); @@ -1117,8 +1123,6 @@ namespace MediaBrowser.MediaEncoding.Probing .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)); } - private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; - /// <summary> /// Splits the specified val. /// </summary> @@ -1138,8 +1142,6 @@ namespace MediaBrowser.MediaEncoding.Probing .Select(i => i.Trim()); } - private const string ArtistReplaceValue = " | "; - private IEnumerable<string> SplitArtists(string val, char[] delimiters, bool splitFeaturing) { if (splitFeaturing) @@ -1169,9 +1171,6 @@ namespace MediaBrowser.MediaEncoding.Probing return artistsFound; } - - private List<string> _splitWhiteList = null; - private IEnumerable<string> GetSplitWhitelist() { if (_splitWhiteList == null) @@ -1248,7 +1247,7 @@ namespace MediaBrowser.MediaEncoding.Probing } /// <summary> - /// Gets the disc number, which is sometimes can be in the form of '1', or '1/3' + /// Gets the disc number, which is sometimes can be in the form of '1', or '1/3'. /// </summary> /// <param name="tags">The tags.</param> /// <param name="tagName">Name of the tag.</param> @@ -1294,8 +1293,6 @@ namespace MediaBrowser.MediaEncoding.Probing return info; } - private const int MaxSubtitleDescriptionExtractionLength = 100; // When extracting subtitles, the maximum length to consider (to avoid invalid filenames) - private void FetchWtvInfo(MediaInfo video, InternalMediaInfoResult data) { if (data.Format == null || data.Format.Tags == null) @@ -1367,7 +1364,9 @@ namespace MediaBrowser.MediaEncoding.Probing // OR -> COMMENT. SUBTITLE: DESCRIPTION // e.g. -> 4/13. The Doctor's Wife: Science fiction drama. When he follows a Time Lord distress signal, the Doctor puts Amy, Rory and his beloved TARDIS in grave danger. Also in HD. [AD,S] // e.g. -> CBeebies Bedtime Hour. The Mystery: Animated adventures of two friends who live on an island in the middle of the big city. Some of Abney and Teal's favourite objects are missing. [S] - if (string.IsNullOrWhiteSpace(subTitle) && !string.IsNullOrWhiteSpace(description) && description.Substring(0, Math.Min(description.Length, MaxSubtitleDescriptionExtractionLength)).Contains(":")) // Check within the Subtitle size limit, otherwise from description it can get too long creating an invalid filename + if (string.IsNullOrWhiteSpace(subTitle) + && !string.IsNullOrWhiteSpace(description) + && description.AsSpan().Slice(0, Math.Min(description.Length, MaxSubtitleDescriptionExtractionLength)).IndexOf(':') != -1) // Check within the Subtitle size limit, otherwise from description it can get too long creating an invalid filename { string[] parts = description.Split(':'); if (parts.Length > 0) @@ -1375,11 +1374,11 @@ namespace MediaBrowser.MediaEncoding.Probing string subtitle = parts[0]; try { - if (subtitle.Contains("/")) // It contains a episode number and season number + if (subtitle.Contains('/', StringComparison.Ordinal)) // It contains a episode number and season number { string[] numbers = subtitle.Split(' '); - video.IndexNumber = int.Parse(numbers[0].Replace(".", "").Split('/')[0]); - int totalEpisodesInSeason = int.Parse(numbers[0].Replace(".", "").Split('/')[1]); + video.IndexNumber = int.Parse(numbers[0].Replace(".", string.Empty, StringComparison.Ordinal).Split('/')[0]); + int totalEpisodesInSeason = int.Parse(numbers[0].Replace(".", string.Empty, StringComparison.Ordinal).Split('/')[1]); description = string.Join(" ", numbers, 1, numbers.Length - 1).Trim(); // Skip the first, concatenate the rest, clean up spaces and save it } @@ -1390,8 +1389,11 @@ namespace MediaBrowser.MediaEncoding.Probing } catch // Default parsing { - if (subtitle.Contains(".")) // skip the comment, keep the subtitle + if (subtitle.Contains('.', StringComparison.Ordinal)) + { + // skip the comment, keep the subtitle description = string.Join(".", subtitle.Split('.'), 1, subtitle.Split('.').Length - 1).Trim(); // skip the first + } else { description = subtitle.Trim(); // Clean up whitespaces and save it diff --git a/MediaBrowser.MediaEncoding/Subtitles/AssParser.cs b/MediaBrowser.MediaEncoding/Subtitles/AssParser.cs index 43a45291c..308b62886 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/AssParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/AssParser.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Globalization; @@ -13,6 +15,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles { private readonly CultureInfo _usCulture = new CultureInfo("en-US"); + /// <inheritdoc /> public SubtitleTrackInfo Parse(Stream stream, CancellationToken cancellationToken) { var trackInfo = new SubtitleTrackInfo(); @@ -22,7 +25,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles { string line; while (reader.ReadLine() != "[Events]") - { } + { + } var headers = ParseFieldHeaders(reader.ReadLine()); @@ -72,17 +76,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles { var fields = line.Substring(8).Split(',').Select(x => x.Trim()).ToList(); - var result = new Dictionary<string, int> { - {"Start", fields.IndexOf("Start")}, - {"End", fields.IndexOf("End")}, - {"Text", fields.IndexOf("Text")} - }; - return result; + return new Dictionary<string, int> + { + { "Start", fields.IndexOf("Start") }, + { "End", fields.IndexOf("End") }, + { "Text", fields.IndexOf("Text") } + }; } - /// <summary> - /// Credit: https://github.com/SubtitleEdit/subtitleedit/blob/master/src/Logic/SubtitleFormats/AdvancedSubStationAlpha.cs - /// </summary> private void RemoteNativeFormatting(SubtitleTrackEvent p) { int indexOfBegin = p.Text.IndexOf('{'); diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs index f0d107196..c0023ebf2 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System.IO; using System.Threading; using MediaBrowser.Model.MediaInfo; diff --git a/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs index bd330f568..6b7a81e6e 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs @@ -8,7 +8,7 @@ using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.MediaEncoding.Subtitles { /// <summary> - /// Credit to https://github.com/SubtitleEdit/subtitleedit/blob/a299dc4407a31796364cc6ad83f0d3786194ba22/src/Logic/SubtitleFormats/SubStationAlpha.cs + /// <see href="https://github.com/SubtitleEdit/subtitleedit/blob/a299dc4407a31796364cc6ad83f0d3786194ba22/src/Logic/SubtitleFormats/SubStationAlpha.cs">Credit</see>. /// </summary> public class SsaParser : ISubtitleParser { @@ -179,10 +179,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles { // h:mm:ss.cc string[] timeCode = time.Split(':', '.'); - return new TimeSpan(0, int.Parse(timeCode[0]), - int.Parse(timeCode[1]), - int.Parse(timeCode[2]), - int.Parse(timeCode[3]) * 10).Ticks; + return new TimeSpan( + 0, + int.Parse(timeCode[0]), + int.Parse(timeCode[1]), + int.Parse(timeCode[2]), + int.Parse(timeCode[3]) * 10).Ticks; } private static string GetFormattedText(string text) @@ -282,6 +284,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles { text = text.Insert(start, "<font color=\"" + color + "\"" + extraTags + ">"); } + int indexOfEndTag = text.IndexOf("{\\c}", start); if (indexOfEndTag > 0) { @@ -320,6 +323,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles { text = text.Insert(start, "<font color=\"" + color + "\"" + extraTags + ">"); } + text += "</font>"; } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 8e6543d80..374e35b96 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -34,6 +34,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles private readonly IHttpClient _httpClient; private readonly IMediaSourceManager _mediaSourceManager; + /// <summary> + /// The _semaphoreLocks. + /// </summary> + private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks = + new ConcurrentDictionary<string, SemaphoreSlim>(); + public SubtitleEncoder( ILibraryManager libraryManager, ILogger<SubtitleEncoder> logger, @@ -174,7 +180,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles inputFiles = new[] { mediaSource.Path }; } - var fileInfo = await GetReadableFile(mediaSource.Path, inputFiles, _mediaSourceManager.GetPathProtocol(subtitleStream.Path), subtitleStream, cancellationToken).ConfigureAwait(false); + var protocol = mediaSource.Protocol; + if (subtitleStream.IsExternal) + { + protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path); + } + + var fileInfo = await GetReadableFile(mediaSource.Path, inputFiles, protocol, subtitleStream, cancellationToken).ConfigureAwait(false); var stream = await GetSubtitleStream(fileInfo.Path, fileInfo.Protocol, fileInfo.IsExternal, cancellationToken).ConfigureAwait(false); @@ -263,25 +275,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles return new SubtitleInfo(subtitleStream.Path, protocol, currentFormat, true); } - private struct SubtitleInfo - { - public SubtitleInfo(string path, MediaProtocol protocol, string format, bool isExternal) - { - Path = path; - Protocol = protocol; - Format = format; - IsExternal = isExternal; - } - - public string Path { get; set; } - - public MediaProtocol Protocol { get; set; } - - public string Format { get; set; } - - public bool IsExternal { get; set; } - } - private ISubtitleParser GetReader(string format, bool throwIfMissing) { if (string.IsNullOrEmpty(format)) @@ -355,12 +348,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles } /// <summary> - /// The _semaphoreLocks. - /// </summary> - private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks = - new ConcurrentDictionary<string, SemaphoreSlim>(); - - /// <summary> /// Gets the lock. /// </summary> /// <param name="filename">The filename.</param> @@ -374,6 +361,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// Converts the text subtitle to SRT. /// </summary> /// <param name="inputPath">The input path.</param> + /// <param name="language">The language.</param> /// <param name="inputProtocol">The input protocol.</param> /// <param name="outputPath">The output path.</param> /// <param name="cancellationToken">The cancellation token.</param> @@ -401,14 +389,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// Converts the text subtitle to SRT internal. /// </summary> /// <param name="inputPath">The input path.</param> + /// <param name="language">The language.</param> /// <param name="inputProtocol">The input protocol.</param> /// <param name="outputPath">The output path.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> /// <exception cref="ArgumentNullException"> - /// inputPath - /// or - /// outputPath + /// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>. /// </exception> private async Task ConvertTextSubtitleToSrtInternal(string inputPath, string language, MediaProtocol inputProtocol, string outputPath, CancellationToken cancellationToken) { @@ -432,7 +419,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles (encodingParam.Equals("UTF-16BE", StringComparison.OrdinalIgnoreCase) || encodingParam.Equals("UTF-16LE", StringComparison.OrdinalIgnoreCase))) { - encodingParam = ""; + encodingParam = string.Empty; } else if (!string.IsNullOrEmpty(encodingParam)) { @@ -534,7 +521,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <param name="outputPath">The output path.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentException">Must use inputPath list overload</exception> + /// <exception cref="ArgumentException">Must use inputPath list overload.</exception> private async Task ExtractTextSubtitle( string[] inputFiles, MediaProtocol protocol, @@ -725,19 +712,19 @@ namespace MediaBrowser.MediaEncoding.Subtitles var date = _fileSystem.GetLastWriteTimeUtc(mediaPath); - var filename = (mediaPath + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension; + ReadOnlySpan<char> filename = (mediaPath + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension; - var prefix = filename.Substring(0, 1); + var prefix = filename.Slice(0, 1); - return Path.Combine(SubtitleCachePath, prefix, filename); + return Path.Join(SubtitleCachePath, prefix, filename); } else { - var filename = (mediaPath + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5() + outputSubtitleExtension; + ReadOnlySpan<char> filename = (mediaPath + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5() + outputSubtitleExtension; - var prefix = filename.Substring(0, 1); + var prefix = filename.Slice(0, 1); - return Path.Combine(SubtitleCachePath, prefix, filename); + return Path.Join(SubtitleCachePath, prefix, filename); } } @@ -753,7 +740,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase) || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase))) { - charset = ""; + charset = string.Empty; } _logger.LogDebug("charset {0} detected for {Path}", charset ?? "null", path); @@ -784,5 +771,24 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw new ArgumentOutOfRangeException(nameof(protocol)); } } + + private struct SubtitleInfo + { + public SubtitleInfo(string path, MediaProtocol protocol, string format, bool isExternal) + { + Path = path; + Protocol = protocol; + Format = format; + IsExternal = isExternal; + } + + public string Path { get; set; } + + public MediaProtocol Protocol { get; set; } + + public string Format { get; set; } + + public bool IsExternal { get; set; } + } } } diff --git a/MediaBrowser.Model/Configuration/BaseApplicationConfiguration.cs b/MediaBrowser.Model/Configuration/BaseApplicationConfiguration.cs index 66f3e1a94..b00d2fffb 100644 --- a/MediaBrowser.Model/Configuration/BaseApplicationConfiguration.cs +++ b/MediaBrowser.Model/Configuration/BaseApplicationConfiguration.cs @@ -52,7 +52,13 @@ namespace MediaBrowser.Model.Configuration public string PreviousVersionStr { get => PreviousVersion?.ToString(); - set => PreviousVersion = Version.Parse(value); + set + { + if (Version.TryParse(value, out var version)) + { + PreviousVersion = version; + } + } } } } diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 82aefbd85..33975bc1e 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -260,6 +260,16 @@ namespace MediaBrowser.Model.Configuration public string[] UninstalledPlugins { get; set; } /// <summary> + /// Gets or sets a value indicating whether slow server responses should be logged as a warning. + /// </summary> + public bool EnableSlowResponseWarning { get; set; } + + /// <summary> + /// Gets or sets the threshold for the slow response time warning in ms. + /// </summary> + public long SlowResponseThresholdMs { get; set; } + + /// <summary> /// Initializes a new instance of the <see cref="ServerConfiguration" /> class. /// </summary> public ServerConfiguration() @@ -365,6 +375,9 @@ namespace MediaBrowser.Model.Configuration DisabledImageFetchers = new[] { "The Open Movie Database", "TheMovieDb" } } }; + + EnableSlowResponseWarning = true; + SlowResponseThresholdMs = 500; } } diff --git a/MediaBrowser.Model/Dlna/SortCriteria.cs b/MediaBrowser.Model/Dlna/SortCriteria.cs index 1f7fa76ad..53e4540cb 100644 --- a/MediaBrowser.Model/Dlna/SortCriteria.cs +++ b/MediaBrowser.Model/Dlna/SortCriteria.cs @@ -1,6 +1,6 @@ #pragma warning disable CS1591 -using MediaBrowser.Model.Entities; +using Jellyfin.Data.Enums; namespace MediaBrowser.Model.Dlna { diff --git a/MediaBrowser.Model/Entities/DisplayPreferences.cs b/MediaBrowser.Model/Entities/DisplayPreferencesDto.cs index 7e5c5be3b..1f7fe3030 100644 --- a/MediaBrowser.Model/Entities/DisplayPreferences.cs +++ b/MediaBrowser.Model/Entities/DisplayPreferencesDto.cs @@ -1,22 +1,18 @@ #nullable disable using System.Collections.Generic; +using Jellyfin.Data.Enums; namespace MediaBrowser.Model.Entities { /// <summary> /// Defines the display preferences for any item that supports them (usually Folders). /// </summary> - public class DisplayPreferences + public class DisplayPreferencesDto { /// <summary> - /// The image scale. + /// Initializes a new instance of the <see cref="DisplayPreferencesDto" /> class. /// </summary> - private const double ImageScale = .9; - - /// <summary> - /// Initializes a new instance of the <see cref="DisplayPreferences" /> class. - /// </summary> - public DisplayPreferences() + public DisplayPreferencesDto() { RememberIndexing = false; PrimaryImageHeight = 250; diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index 1b37cfc93..f9ec0d238 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -233,7 +233,7 @@ namespace MediaBrowser.Model.Entities if (!string.IsNullOrEmpty(Title)) { - var result = new StringBuilder(Title); + var result = new StringBuilder(Title); foreach (var tag in attributes) { // Keep Tags that are not already in Title. @@ -246,7 +246,7 @@ namespace MediaBrowser.Model.Entities return result.ToString(); } - return string.Join(" - ", attributes.ToArray()); + return string.Join(" - ", attributes); } default: diff --git a/MediaBrowser.Model/Entities/ScrollDirection.cs b/MediaBrowser.Model/Entities/ScrollDirection.cs deleted file mode 100644 index a1de0edcb..000000000 --- a/MediaBrowser.Model/Entities/ScrollDirection.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace MediaBrowser.Model.Entities -{ - /// <summary> - /// Enum ScrollDirection. - /// </summary> - public enum ScrollDirection - { - /// <summary> - /// The horizontal. - /// </summary> - Horizontal, - - /// <summary> - /// The vertical. - /// </summary> - Vertical - } -} diff --git a/MediaBrowser.Model/Entities/SortOrder.cs b/MediaBrowser.Model/Entities/SortOrder.cs deleted file mode 100644 index f3abc06f3..000000000 --- a/MediaBrowser.Model/Entities/SortOrder.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace MediaBrowser.Model.Entities -{ - /// <summary> - /// Enum SortOrder. - /// </summary> - public enum SortOrder - { - /// <summary> - /// The ascending. - /// </summary> - Ascending, - - /// <summary> - /// The descending. - /// </summary> - Descending - } -} diff --git a/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs b/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs index 2b2377fda..ab74aff28 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs @@ -2,7 +2,7 @@ #pragma warning disable CS1591 using System; -using MediaBrowser.Model.Entities; +using Jellyfin.Data.Enums; namespace MediaBrowser.Model.LiveTv { diff --git a/MediaBrowser.Model/LiveTv/SeriesTimerQuery.cs b/MediaBrowser.Model/LiveTv/SeriesTimerQuery.cs index b899a464b..dae885775 100644 --- a/MediaBrowser.Model/LiveTv/SeriesTimerQuery.cs +++ b/MediaBrowser.Model/LiveTv/SeriesTimerQuery.cs @@ -1,6 +1,6 @@ #pragma warning disable CS1591 -using MediaBrowser.Model.Entities; +using Jellyfin.Data.Enums; namespace MediaBrowser.Model.LiveTv { diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index f655b8edd..32b543fef 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -125,7 +125,7 @@ namespace MediaBrowser.Providers.Manager // If there are more than one output paths, the stream will need to be seekable var memoryStream = new MemoryStream(); - using (source) + await using (source.ConfigureAwait(false)) { await source.CopyToAsync(memoryStream).ConfigureAwait(false); } @@ -138,7 +138,7 @@ namespace MediaBrowser.Providers.Manager var savedPaths = new List<string>(); - await using (source) + await using (source.ConfigureAwait(false)) { var currentPathIndex = 0; diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 9170c7002..bbd7166e6 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Net; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; @@ -22,6 +24,7 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Events; using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Priority_Queue; @@ -169,6 +172,15 @@ namespace MediaBrowser.Providers.Manager } } + // thetvdb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons... + if (response.ContentType.Equals(MediaTypeNames.Text.Html, StringComparison.OrdinalIgnoreCase)) + { + throw new HttpException("Invalid image received.") + { + StatusCode = HttpStatusCode.NotFound + }; + } + await SaveImage(item, response.Content, response.ContentType, type, imageIndex, cancellationToken).ConfigureAwait(false); } @@ -966,7 +978,7 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public void OnRefreshStart(BaseItem item) { - _logger.LogInformation("OnRefreshStart {0}", item.Id.ToString("N", CultureInfo.InvariantCulture)); + _logger.LogDebug("OnRefreshStart {0}", item.Id.ToString("N", CultureInfo.InvariantCulture)); _activeRefreshes[item.Id] = 0; RefreshStarted?.Invoke(this, new GenericEventArgs<BaseItem>(item)); } @@ -974,7 +986,7 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public void OnRefreshComplete(BaseItem item) { - _logger.LogInformation("OnRefreshComplete {0}", item.Id.ToString("N", CultureInfo.InvariantCulture)); + _logger.LogDebug("OnRefreshComplete {0}", item.Id.ToString("N", CultureInfo.InvariantCulture)); _activeRefreshes.Remove(item.Id, out _); diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 42c7cec53..7c0b54250 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -19,8 +19,8 @@ <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.6" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="3.1.6" /> <PackageReference Include="OptimizedPriorityQueue" Version="4.2.0" /> - <PackageReference Include="PlaylistsNET" Version="1.0.6" /> - <PackageReference Include="TvDbSharper" Version="3.2.0" /> + <PackageReference Include="PlaylistsNET" Version="1.1.2" /> + <PackageReference Include="TvDbSharper" Version="3.2.1" /> </ItemGroup> <PropertyGroup> diff --git a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs index 80acb2c05..f69ec9744 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs @@ -93,7 +93,7 @@ namespace MediaBrowser.Providers.MediaInfo private string GetAudioImagePath(Audio item) { - string filename = null; + string filename; if (item.GetType() == typeof(Audio)) { @@ -116,9 +116,9 @@ namespace MediaBrowser.Providers.MediaInfo filename = item.Id.ToString("N", CultureInfo.InvariantCulture) + ".jpg"; } - var prefix = filename.Substring(0, 1); + var prefix = filename.AsSpan().Slice(0, 1); - return Path.Combine(AudioImagesPath, prefix, filename); + return Path.Join(AudioImagesPath, prefix, filename); } public string AudioImagesPath => Path.Combine(_config.ApplicationPaths.CachePath, "extracted-audio-images"); diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs index cb7c0362e..54054d015 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs @@ -19,6 +19,9 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public override string Description => "Get artist and album metadata or images from AudioDB."; + // TODO remove when plugin removed from server. + public override string ConfigurationFileName => "Jellyfin.Plugin.AudioDb.xml"; + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) { diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs index a7e6267da..90266e440 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs @@ -23,6 +23,9 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz public const long DefaultRateLimit = 2000u; + // TODO remove when plugin removed from server. + public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml"; + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) { diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index 944ba26af..12aecba84 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -170,7 +170,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb item.SetProviderId(MetadataProvider.Imdb, result.imdbID); if (result.Year.Length > 0 - && int.TryParse(result.Year.Substring(0, Math.Min(result.Year.Length, 4)), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedYear)) + && int.TryParse(result.Year.AsSpan().Slice(0, Math.Min(result.Year.Length, 4)), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedYear)) { item.ProductionYear = parsedYear; } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 9700f3b18..13098d140 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -62,7 +62,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } if (!string.IsNullOrEmpty(result.Year) && result.Year.Length >= 4 - && int.TryParse(result.Year.Substring(0, 4), NumberStyles.Number, _usCulture, out var year) + && int.TryParse(result.Year.AsSpan().Slice(0, 4), NumberStyles.Number, _usCulture, out var year) && year >= 0) { item.ProductionYear = year; @@ -163,7 +163,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } if (!string.IsNullOrEmpty(result.Year) && result.Year.Length >= 4 - && int.TryParse(result.Year.Substring(0, 4), NumberStyles.Number, _usCulture, out var year) + && int.TryParse(result.Year.AsSpan().Slice(0, 4), NumberStyles.Number, _usCulture, out var year) && year >= 0) { item.ProductionYear = year; diff --git a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs index 4cf5f4ce6..41ca56164 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs @@ -19,6 +19,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb public override string Description => "Get metadata for movies and other video content from OMDb."; + // TODO remove when plugin removed from server. + public override string ConfigurationFileName => "Jellyfin.Plugin.Omdb.xml"; + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) { diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs b/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs index aa5f819f0..e7079ed3c 100644 --- a/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs @@ -17,6 +17,9 @@ namespace MediaBrowser.Providers.Plugins.TheTvdb public override string Description => "Get metadata for movies and other video content from TheTVDB."; + // TODO remove when plugin removed from server. + public override string ConfigurationFileName => "Jellyfin.Plugin.TheTvdb.xml"; + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) { diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs index ced287d54..52fc53872 100644 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs @@ -188,7 +188,7 @@ namespace MediaBrowser.Providers.Plugins.TheTvdb for (var i = 0; i < episode.GuestStars.Length; ++i) { var currentActor = episode.GuestStars[i]; - var roleStartIndex = currentActor.IndexOf('('); + var roleStartIndex = currentActor.IndexOf('(', StringComparison.Ordinal); if (roleStartIndex == -1) { @@ -207,7 +207,7 @@ namespace MediaBrowser.Providers.Plugins.TheTvdb for (var j = i + 1; j < episode.GuestStars.Length; ++j) { var currentRole = episode.GuestStars[j]; - var roleEndIndex = currentRole.IndexOf(')'); + var roleEndIndex = currentRole.IndexOf(')', StringComparison.Ordinal); if (roleEndIndex == -1) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Models/General/ExternalIds.cs b/MediaBrowser.Providers/Plugins/Tmdb/Models/General/ExternalIds.cs index 310c871ec..aac4420e8 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Models/General/ExternalIds.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Models/General/ExternalIds.cs @@ -10,8 +10,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Models.General public string Freebase_Mid { get; set; } - public int Tvdb_Id { get; set; } + public int? Tvdb_Id { get; set; } - public int Tvrage_Id { get; set; } + public int? Tvrage_Id { get; set; } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Models/Movies/MovieResult.cs b/MediaBrowser.Providers/Plugins/Tmdb/Models/Movies/MovieResult.cs index 7566df8b6..704ebcd5a 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Models/Movies/MovieResult.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Models/Movies/MovieResult.cs @@ -13,7 +13,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Models.Movies public BelongsToCollection Belongs_To_Collection { get; set; } - public int Budget { get; set; } + public long Budget { get; set; } public List<Genre> Genres { get; set; } @@ -39,7 +39,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Models.Movies public string Release_Date { get; set; } - public int Revenue { get; set; } + public long Revenue { get; set; } public int Runtime { get; set; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 74870c999..27ab6756f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -406,26 +406,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies return mainResult; } - private static long _lastRequestTicks; - // The limit is 40 requests per 10 seconds - private const int RequestIntervalMs = 300; - /// <summary> /// Gets the movie db response. /// </summary> internal async Task<HttpResponseInfo> GetMovieDbResponse(HttpRequestOptions options) { - var delayTicks = (RequestIntervalMs * 10000) - (DateTime.UtcNow.Ticks - _lastRequestTicks); - var delayMs = Math.Min(delayTicks / 10000, RequestIntervalMs); - - if (delayMs > 0) - { - _logger.LogDebug("Throttling Tmdb by {0} ms", delayMs); - await Task.Delay(Convert.ToInt32(delayMs)).ConfigureAwait(false); - } - - _lastRequestTicks = DateTime.UtcNow.Ticks; - options.BufferContent = true; options.UserAgent = _appHost.ApplicationUserAgent; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 76d3f8224..58cbf9eef 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -251,9 +251,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People private static string GetPersonDataPath(IApplicationPaths appPaths, string tmdbId) { - var letter = tmdbId.GetMD5().ToString().Substring(0, 1); + var letter = tmdbId.GetMD5().ToString().AsSpan().Slice(0, 1); - return Path.Combine(GetPersonsDataPath(appPaths), letter, tmdbId); + return Path.Join(GetPersonsDataPath(appPaths), letter, tmdbId); } internal static string GetPersonDataFilePath(IApplicationPaths appPaths, string tmdbId) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 0c55b91e0..a07ceb40e 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -111,7 +111,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (response.External_Ids.Tvdb_Id > 0) { - item.SetProviderId(MetadataProvider.Tvdb, response.External_Ids.Tvdb_Id.ToString(CultureInfo.InvariantCulture)); + item.SetProviderId(MetadataProvider.Tvdb, response.External_Ids.Tvdb_Id.Value.ToString(CultureInfo.InvariantCulture)); } item.PremiereDate = response.Air_Date; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 11f21333c..822dc4317 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -75,7 +75,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (seasonInfo.External_Ids.Tvdb_Id > 0) { - result.Item.SetProviderId(MetadataProvider.Tvdb, seasonInfo.External_Ids.Tvdb_Id.ToString(CultureInfo.InvariantCulture)); + result.Item.SetProviderId(MetadataProvider.Tvdb, seasonInfo.External_Ids.Tvdb_Id.Value.ToString(CultureInfo.InvariantCulture)); } var credits = seasonInfo.Credits; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index f142fd29c..3480a15f6 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -92,7 +92,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (obj.External_Ids.Tvdb_Id > 0) { - remoteResult.SetProviderId(MetadataProvider.Tvdb, obj.External_Ids.Tvdb_Id.ToString(_usCulture)); + remoteResult.SetProviderId(MetadataProvider.Tvdb, obj.External_Ids.Tvdb_Id.Value.ToString(_usCulture)); } return new[] { remoteResult }; @@ -268,12 +268,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (ids.Tvrage_Id > 0) { - series.SetProviderId(MetadataProvider.TvRage, ids.Tvrage_Id.ToString(_usCulture)); + series.SetProviderId(MetadataProvider.TvRage, ids.Tvrage_Id.Value.ToString(_usCulture)); } if (ids.Tvdb_Id > 0) { - series.SetProviderId(MetadataProvider.Tvdb, ids.Tvdb_Id.ToString(_usCulture)); + series.SetProviderId(MetadataProvider.Tvdb, ids.Tvdb_Id.Value.ToString(_usCulture)); } } diff --git a/MediaBrowser.WebDashboard/Api/DashboardService.cs b/MediaBrowser.WebDashboard/Api/DashboardService.cs deleted file mode 100644 index 63cbfd9e4..000000000 --- a/MediaBrowser.WebDashboard/Api/DashboardService.cs +++ /dev/null @@ -1,340 +0,0 @@ -#pragma warning disable CS1591 -#pragma warning disable SA1402 -#pragma warning disable SA1649 - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Plugins; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Extensions; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Plugins; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.WebDashboard.Api -{ - /// <summary> - /// Class GetDashboardConfigurationPages. - /// </summary> - [Route("/web/ConfigurationPages", "GET")] - public class GetDashboardConfigurationPages : IReturn<List<ConfigurationPageInfo>> - { - /// <summary> - /// Gets or sets the type of the page. - /// </summary> - /// <value>The type of the page.</value> - public ConfigurationPageType? PageType { get; set; } - - public bool? EnableInMainMenu { get; set; } - } - - /// <summary> - /// Class GetDashboardConfigurationPage. - /// </summary> - [Route("/web/ConfigurationPage", "GET")] - public class GetDashboardConfigurationPage - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - } - - [Route("/robots.txt", "GET", IsHidden = true)] - public class GetRobotsTxt - { - } - - /// <summary> - /// Class GetDashboardResource. - /// </summary> - [Route("/web/{ResourceName*}", "GET", IsHidden = true)] - public class GetDashboardResource - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string ResourceName { get; set; } - - /// <summary> - /// Gets or sets the V. - /// </summary> - /// <value>The V.</value> - public string V { get; set; } - } - - [Route("/favicon.ico", "GET", IsHidden = true)] - public class GetFavIcon - { - } - - /// <summary> - /// Class DashboardService. - /// </summary> - public class DashboardService : IService, IRequiresRequest - { - /// <summary> - /// Gets or sets the logger. - /// </summary> - /// <value>The logger.</value> - private readonly ILogger<DashboardService> _logger; - - /// <summary> - /// Gets or sets the HTTP result factory. - /// </summary> - /// <value>The HTTP result factory.</value> - private readonly IHttpResultFactory _resultFactory; - private readonly IServerApplicationHost _appHost; - private readonly IConfiguration _appConfig; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IFileSystem _fileSystem; - private readonly IResourceFileManager _resourceFileManager; - - /// <summary> - /// Initializes a new instance of the <see cref="DashboardService" /> class. - /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="appHost">The application host.</param> - /// <param name="appConfig">The application configuration.</param> - /// <param name="resourceFileManager">The resource file manager.</param> - /// <param name="serverConfigurationManager">The server configuration manager.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="resultFactory">The result factory.</param> - public DashboardService( - ILogger<DashboardService> logger, - IServerApplicationHost appHost, - IConfiguration appConfig, - IResourceFileManager resourceFileManager, - IServerConfigurationManager serverConfigurationManager, - IFileSystem fileSystem, - IHttpResultFactory resultFactory) - { - _logger = logger; - _appHost = appHost; - _appConfig = appConfig; - _resourceFileManager = resourceFileManager; - _serverConfigurationManager = serverConfigurationManager; - _fileSystem = fileSystem; - _resultFactory = resultFactory; - } - - /// <summary> - /// Gets or sets the request context. - /// </summary> - /// <value>The request context.</value> - public IRequest Request { get; set; } - - /// <summary> - /// Gets the path of the directory containing the static web interface content, or null if the server is not - /// hosting the web client. - /// </summary> - public string DashboardUIPath => GetDashboardUIPath(_appConfig, _serverConfigurationManager); - - /// <summary> - /// Gets the path of the directory containing the static web interface content. - /// </summary> - /// <param name="appConfig">The app configuration.</param> - /// <param name="serverConfigManager">The server configuration manager.</param> - /// <returns>The directory path, or null if the server is not hosting the web client.</returns> - public static string GetDashboardUIPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager) - { - if (!appConfig.HostWebClient()) - { - return null; - } - - if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath)) - { - return serverConfigManager.Configuration.DashboardSourcePath; - } - - return serverConfigManager.ApplicationPaths.WebPath; - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetFavIcon request) - { - return Get(new GetDashboardResource - { - ResourceName = "favicon.ico" - }); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public Task<object> Get(GetDashboardConfigurationPage request) - { - IPlugin plugin = null; - Stream stream = null; - - var isJs = false; - var isTemplate = false; - - var page = ServerEntryPoint.Instance.PluginConfigurationPages.FirstOrDefault(p => string.Equals(p.Name, request.Name, StringComparison.OrdinalIgnoreCase)); - if (page != null) - { - plugin = page.Plugin; - stream = page.GetHtmlStream(); - } - - if (plugin == null) - { - var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, request.Name, StringComparison.OrdinalIgnoreCase)); - if (altPage != null) - { - plugin = altPage.Item2; - stream = plugin.GetType().Assembly.GetManifestResourceStream(altPage.Item1.EmbeddedResourcePath); - - isJs = string.Equals(Path.GetExtension(altPage.Item1.EmbeddedResourcePath), ".js", StringComparison.OrdinalIgnoreCase); - isTemplate = altPage.Item1.EmbeddedResourcePath.EndsWith(".template.html", StringComparison.Ordinal); - } - } - - if (plugin != null && stream != null) - { - if (isJs) - { - return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.js"), () => Task.FromResult(stream)); - } - - if (isTemplate) - { - return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => Task.FromResult(stream)); - } - - return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => Task.FromResult(stream)); - } - - throw new ResourceNotFoundException(); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetDashboardConfigurationPages request) - { - const string unavailableMessage = "The server is still loading. Please try again momentarily."; - - var instance = ServerEntryPoint.Instance; - - if (instance == null) - { - throw new InvalidOperationException(unavailableMessage); - } - - var pages = instance.PluginConfigurationPages; - - if (pages == null) - { - throw new InvalidOperationException(unavailableMessage); - } - - // Don't allow a failing plugin to fail them all - var configPages = pages.Select(p => - { - try - { - return new ConfigurationPageInfo(p); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting plugin information from {Plugin}", p.GetType().Name); - return null; - } - }) - .Where(i => i != null) - .ToList(); - - configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages)); - - if (request.PageType.HasValue) - { - configPages = configPages.Where(p => p.ConfigurationPageType == request.PageType.Value).ToList(); - } - - if (request.EnableInMainMenu.HasValue) - { - configPages = configPages.Where(p => p.EnableInMainMenu == request.EnableInMainMenu.Value).ToList(); - } - - return configPages; - } - - private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages() - { - return _appHost.Plugins.SelectMany(GetPluginPages); - } - - private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(IPlugin plugin) - { - var hasConfig = plugin as IHasWebPages; - - if (hasConfig == null) - { - return new List<Tuple<PluginPageInfo, IPlugin>>(); - } - - return hasConfig.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin)); - } - - private IEnumerable<ConfigurationPageInfo> GetConfigPages(IPlugin plugin) - { - return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1)); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetRobotsTxt request) - { - return Get(new GetDashboardResource - { - ResourceName = "robots.txt" - }); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Get(GetDashboardResource request) - { - if (!_appConfig.HostWebClient() || DashboardUIPath == null) - { - throw new ResourceNotFoundException(); - } - - var path = request?.ResourceName; - var basePath = DashboardUIPath; - - // Bounce them to the startup wizard if it hasn't been completed yet - if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted - && !Request.RawUrl.Contains("wizard", StringComparison.OrdinalIgnoreCase) - && Request.RawUrl.Contains("index", StringComparison.OrdinalIgnoreCase)) - { - Request.Response.Redirect("index.html?start=wizard#!/wizardstart.html"); - return null; - } - - return await _resultFactory.GetStaticFileResult(Request, _resourceFileManager.GetResourcePath(basePath, path)).ConfigureAwait(false); - } - } -} diff --git a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj b/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj deleted file mode 100644 index bcaee50f2..000000000 --- a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj +++ /dev/null @@ -1,42 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> - <PropertyGroup> - <ProjectGuid>{5624B7B5-B5A7-41D8-9F10-CC5611109619}</ProjectGuid> - </PropertyGroup> - - <ItemGroup> - <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> - <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> - </ItemGroup> - - <ItemGroup> - <Compile Include="..\SharedVersion.cs" /> - </ItemGroup> - - <ItemGroup> - <None Include="jellyfin-web\**\*.*"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - </ItemGroup> - - <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - </PropertyGroup> - - <!-- Code Analyzers--> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> - </ItemGroup> - - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - -</Project> diff --git a/MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs b/MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs deleted file mode 100644 index 584d49021..000000000 --- a/MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Reflection; -using System.Resources; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("MediaBrowser.WebDashboard")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Jellyfin Project")] -[assembly: AssemblyProduct("Jellyfin Server")] -[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: NeutralResourcesLanguage("en")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] diff --git a/MediaBrowser.WebDashboard/ServerEntryPoint.cs b/MediaBrowser.WebDashboard/ServerEntryPoint.cs deleted file mode 100644 index 5c7e8b3c7..000000000 --- a/MediaBrowser.WebDashboard/ServerEntryPoint.cs +++ /dev/null @@ -1,42 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common; -using MediaBrowser.Controller.Plugins; - -namespace MediaBrowser.WebDashboard -{ - public sealed class ServerEntryPoint : IServerEntryPoint - { - private readonly IApplicationHost _appHost; - - public ServerEntryPoint(IApplicationHost appHost) - { - _appHost = appHost; - Instance = this; - } - - public static ServerEntryPoint Instance { get; private set; } - - /// <summary> - /// Gets the list of plugin configuration pages. - /// </summary> - /// <value>The configuration pages.</value> - public List<IPluginConfigurationPage> PluginConfigurationPages { get; private set; } - - /// <inheritdoc /> - public Task RunAsync() - { - PluginConfigurationPages = _appHost.GetExports<IPluginConfigurationPage>().ToList(); - - return Task.CompletedTask; - } - - /// <inheritdoc /> - public void Dispose() - { - } - } -} diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index b2d99c1a1..b06464409 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -222,8 +222,16 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (index != -1) { - var tmdbId = xml.Substring(index + srch.Length).TrimEnd('/').Split('-')[0]; - if (!string.IsNullOrWhiteSpace(tmdbId) && int.TryParse(tmdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + var tmdbId = xml.AsSpan().Slice(index + srch.Length).TrimEnd('/'); + index = tmdbId.IndexOf('-'); + if (index != -1) + { + tmdbId = tmdbId.Slice(0, index); + } + + if (!tmdbId.IsEmpty + && !tmdbId.IsWhiteSpace() + && int.TryParse(tmdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) { item.SetProviderId(MetadataProvider.Tmdb, value.ToString(UsCulture)); } @@ -237,8 +245,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (index != -1) { - var tvdbId = xml.Substring(index + srch.Length).TrimEnd('/'); - if (!string.IsNullOrWhiteSpace(tvdbId) && int.TryParse(tvdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + var tvdbId = xml.AsSpan().Slice(index + srch.Length).TrimEnd('/'); + if (!tvdbId.IsEmpty + && !tvdbId.IsWhiteSpace() + && int.TryParse(tvdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) { item.SetProviderId(MetadataProvider.Tvdb, value.ToString(UsCulture)); } @@ -442,8 +452,8 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var val = reader.ReadElementContentAsString(); - var hasAspectRatio = item as IHasAspectRatio; - if (!string.IsNullOrWhiteSpace(val) && hasAspectRatio != null) + if (!string.IsNullOrWhiteSpace(val) + && item is IHasAspectRatio hasAspectRatio) { hasAspectRatio.AspectRatio = val; } diff --git a/MediaBrowser.sln b/MediaBrowser.sln index e100c0b1c..75587da1f 100644 --- a/MediaBrowser.sln +++ b/MediaBrowser.sln @@ -6,14 +6,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server", "Jellyfin EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Controller", "MediaBrowser.Controller\MediaBrowser.Controller.csproj", "{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Api", "MediaBrowser.Api\MediaBrowser.Api.csproj", "{4FD51AC5-2C16-4308-A993-C3A84F3B4582}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Common", "MediaBrowser.Common\MediaBrowser.Common.csproj", "{9142EEFA-7570-41E1-BFCC-468BB571AF2F}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Model", "MediaBrowser.Model\MediaBrowser.Model.csproj", "{7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.WebDashboard", "MediaBrowser.WebDashboard\MediaBrowser.WebDashboard.csproj", "{5624B7B5-B5A7-41D8-9F10-CC5611109619}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Providers", "MediaBrowser.Providers\MediaBrowser.Providers.csproj", "{442B5058-DCAF-4263-BB6A-F21E31120A1B}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.XbmcMetadata", "MediaBrowser.XbmcMetadata\MediaBrowser.XbmcMetadata.csproj", "{23499896-B135-4527-8574-C26E926EA99E}" @@ -82,10 +78,6 @@ Global {17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Debug|Any CPU.Build.0 = Debug|Any CPU {17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Release|Any CPU.ActiveCfg = Release|Any CPU {17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Release|Any CPU.Build.0 = Release|Any CPU - {4FD51AC5-2C16-4308-A993-C3A84F3B4582}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4FD51AC5-2C16-4308-A993-C3A84F3B4582}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4FD51AC5-2C16-4308-A993-C3A84F3B4582}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4FD51AC5-2C16-4308-A993-C3A84F3B4582}.Release|Any CPU.Build.0 = Release|Any CPU {9142EEFA-7570-41E1-BFCC-468BB571AF2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9142EEFA-7570-41E1-BFCC-468BB571AF2F}.Debug|Any CPU.Build.0 = Debug|Any CPU {9142EEFA-7570-41E1-BFCC-468BB571AF2F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -94,10 +86,6 @@ Global {7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}.Debug|Any CPU.Build.0 = Debug|Any CPU {7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}.Release|Any CPU.ActiveCfg = Release|Any CPU {7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}.Release|Any CPU.Build.0 = Release|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Release|Any CPU.Build.0 = Release|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Debug|Any CPU.Build.0 = Debug|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -100,7 +100,7 @@ Note that it is also possible to [host the web client separately](#hosting-the-w There are three options to get the files for the web client. -1. Download one of the finished builds from the [Azure DevOps pipeline](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=11). You can download the build for a specific release by looking at the [branches tab](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=11&_a=summary&repositoryFilter=6&view=branches) of the pipelines page. +1. Download one of the finished builds from the [Azure DevOps pipeline](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27). You can download the build for a specific release by looking at the [branches tab](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27&_a=summary&repositoryFilter=6&view=branches) of the pipelines page. 2. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web) 3. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web` @@ -162,6 +162,9 @@ The following sections describe some more advanced scenarios for running the ser It is not necessary to host the frontend web client as part of the backend server. Hosting these two components separately may be useful for frontend developers who would prefer to host the client in a separate webpack development server for a tighter development loop. See the [jellyfin-web](https://github.com/jellyfin/jellyfin-web#getting-started) repo for instructions on how to do this. -To instruct the server not to host the web content, there is a `nowebcontent` configuration flag that must be set. This can specified using the command line switch `--nowebcontent` or the environment variable `JELLYFIN_NOWEBCONTENT=true`. +To instruct the server not to host the web content, there is a `nowebclient` configuration flag that must be set. This can specified using the command line +switch `--nowebclient` or the environment variable `JELLYFIN_NOWEBCONTENT=true`. Since this is a common scenario, there is also a separate launch profile defined for Visual Studio called `Jellyfin.Server (nowebcontent)` that can be selected from the 'Start Debugging' dropdown in the main toolbar. + +**NOTE:** The setup wizard can not be run if the web client is hosted separately. diff --git a/RSSDP/RSSDP.csproj b/RSSDP/RSSDP.csproj index 553693171..664663bd7 100644 --- a/RSSDP/RSSDP.csproj +++ b/RSSDP/RSSDP.csproj @@ -6,9 +6,7 @@ </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> - <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> </ItemGroup> <PropertyGroup> diff --git a/SharedVersion.cs b/SharedVersion.cs index 6981c1ca9..d17074fc0 100644 --- a/SharedVersion.cs +++ b/SharedVersion.cs @@ -1,4 +1,4 @@ using System.Reflection; -[assembly: AssemblyVersion("10.6.0")] -[assembly: AssemblyFileVersion("10.6.0")] +[assembly: AssemblyVersion("10.7.0")] +[assembly: AssemblyFileVersion("10.7.0")] diff --git a/build.yaml b/build.yaml index 9e590e5a0..7c32d955c 100644 --- a/build.yaml +++ b/build.yaml @@ -1,7 +1,7 @@ --- # We just wrap `build` so this is really it name: "jellyfin" -version: "10.6.0" +version: "10.7.0" packages: - debian.amd64 - debian.arm64 diff --git a/bump_version b/bump_version index 46b7f86e0..1c943f691 100755 --- a/bump_version +++ b/bump_version @@ -4,6 +4,7 @@ set -o errexit set -o pipefail +set -o xtrace usage() { echo -e "bump_version - increase the shared version and generate changelogs" @@ -58,7 +59,7 @@ sed -i "s/${old_version_sed}/${new_version}/g" ${debian_equivs_file} debian_changelog_file="debian/changelog" debian_changelog_temp="$( mktemp )" # Create new temp file with our changelog -echo -e "jellyfin (${new_version_deb}) unstable; urgency=medium +echo -e "jellyfin-server (${new_version_deb}) unstable; urgency=medium * New upstream version ${new_version}; release changelog at https://github.com/jellyfin/jellyfin/releases/tag/v${new_version} diff --git a/debian/changelog b/debian/changelog index d38d03805..184143fe9 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jellyfin-server (10.7.0-1) unstable; urgency=medium + + * Forthcoming stable release + + -- Jellyfin Packaging Team <packaging@jellyfin.org> Mon, 27 Jul 2020 19:09:45 -0400 + jellyfin-server (10.6.0-2) unstable; urgency=medium * Fix upgrade bug diff --git a/debian/metapackage/jellyfin b/debian/metapackage/jellyfin index 9a41eafae..026fcb821 100644 --- a/debian/metapackage/jellyfin +++ b/debian/metapackage/jellyfin @@ -5,7 +5,7 @@ Homepage: https://jellyfin.org Standards-Version: 3.9.2 Package: jellyfin -Version: 10.6.0 +Version: 10.7.0 Maintainer: Jellyfin Packaging Team <packaging@jellyfin.org> Depends: jellyfin-server, jellyfin-web Description: Provides the Jellyfin Free Software Media System diff --git a/deployment/Dockerfile.debian.amd64 b/deployment/Dockerfile.debian.amd64 index b5a038048..f9c6a1674 100644 --- a/deployment/Dockerfile.debian.amd64 +++ b/deployment/Dockerfile.debian.amd64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.debian.arm64 b/deployment/Dockerfile.debian.arm64 index cfe562df3..5c08444df 100644 --- a/deployment/Dockerfile.debian.arm64 +++ b/deployment/Dockerfile.debian.arm64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.debian.armhf b/deployment/Dockerfile.debian.armhf index ea8c8c8e6..b54fc3f91 100644 --- a/deployment/Dockerfile.debian.armhf +++ b/deployment/Dockerfile.debian.armhf @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.linux.amd64 b/deployment/Dockerfile.linux.amd64 index d8bec9214..3a2f67615 100644 --- a/deployment/Dockerfile.linux.amd64 +++ b/deployment/Dockerfile.linux.amd64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.macos b/deployment/Dockerfile.macos index ba5da4019..d1c0784ff 100644 --- a/deployment/Dockerfile.macos +++ b/deployment/Dockerfile.macos @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.portable b/deployment/Dockerfile.portable index 2893e140d..7270188fd 100644 --- a/deployment/Dockerfile.portable +++ b/deployment/Dockerfile.portable @@ -15,7 +15,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index e61be4efc..b7d3b4bde 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index f91b91cd4..dc90f9fbd 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index 85414614c..db98610c9 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.windows.amd64 b/deployment/Dockerfile.windows.amd64 index 0397a023e..95fd10cf4 100644 --- a/deployment/Dockerfile.windows.amd64 +++ b/deployment/Dockerfile.windows.amd64 @@ -15,7 +15,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/d731f991-8e68-4c7c-8ea0-fad5605b077a/49497b5420eecbd905158d86d738af64/dotnet-sdk-3.1.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/c1a30ceb-adc2-4244-b24a-06ca29bb1ee9/6df5d856ff1b3e910d283f89690b7cae/dotnet-sdk-3.1.302-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec index 9311864a6..b0a729a9e 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -7,7 +7,7 @@ %endif Name: jellyfin -Version: 10.6.0 +Version: 10.7.0 Release: 1%{?dist} Summary: The Free Software Media System License: GPLv3 @@ -139,6 +139,8 @@ fi %systemd_postun_with_restart jellyfin.service %changelog +* Mon Jul 27 2020 Jellyfin Packaging Team <packaging@jellyfin.org> +- Forthcoming stable release * Mon Mar 23 2020 Jellyfin Packaging Team <packaging@jellyfin.org> - Forthcoming stable release * Fri Oct 11 2019 Jellyfin Packaging Team <packaging@jellyfin.org> diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs index a0f36ebbf..4ea5094b6 100644 --- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -132,7 +132,7 @@ namespace Jellyfin.Api.Tests.Auth _jellyfinAuthServiceMock.Setup( a => a.Authenticate( It.IsAny<HttpRequest>())) - .Returns(authorizationInfo); + .Returns(authorizationInfo); return authorizationInfo; } diff --git a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs new file mode 100644 index 000000000..a62fd8d5a --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Auth.DefaultAuthorizationPolicy +{ + public class DefaultAuthorizationHandlerTests + { + private readonly Mock<IConfigurationManager> _configurationManagerMock; + private readonly List<IAuthorizationRequirement> _requirements; + private readonly DefaultAuthorizationHandler _sut; + private readonly Mock<IUserManager> _userManagerMock; + private readonly Mock<IHttpContextAccessor> _httpContextAccessor; + + public DefaultAuthorizationHandlerTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); + _requirements = new List<IAuthorizationRequirement> { new DefaultAuthorizationRequirement() }; + _userManagerMock = fixture.Freeze<Mock<IUserManager>>(); + _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); + + _sut = fixture.Create<DefaultAuthorizationHandler>(); + } + + [Theory] + [InlineData(UserRoles.Administrator)] + [InlineData(UserRoles.Guest)] + [InlineData(UserRoles.User)] + public async Task ShouldSucceedOnUser(string userRole) + { + TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); + var claims = TestHelpers.SetupUser( + _userManagerMock, + _httpContextAccessor, + userRole); + + var context = new AuthorizationHandlerContext(_requirements, claims, null); + + await _sut.HandleAsync(context); + Assert.True(context.HasSucceeded); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs index e40af703f..ee42216e4 100644 --- a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs @@ -1,13 +1,13 @@ using System.Collections.Generic; -using System.Security.Claims; using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; using Jellyfin.Api.Constants; using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Configuration; +using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Moq; using Xunit; @@ -18,12 +18,16 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy private readonly Mock<IConfigurationManager> _configurationManagerMock; private readonly List<IAuthorizationRequirement> _requirements; private readonly FirstTimeSetupOrElevatedHandler _sut; + private readonly Mock<IUserManager> _userManagerMock; + private readonly Mock<IHttpContextAccessor> _httpContextAccessor; public FirstTimeSetupOrElevatedHandlerTests() { var fixture = new Fixture().Customize(new AutoMoqCustomization()); _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); _requirements = new List<IAuthorizationRequirement> { new FirstTimeSetupOrElevatedRequirement() }; + _userManagerMock = fixture.Freeze<Mock<IUserManager>>(); + _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); _sut = fixture.Create<FirstTimeSetupOrElevatedHandler>(); } @@ -34,9 +38,13 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy [InlineData(UserRoles.User)] public async Task ShouldSucceedIfStartupWizardIncomplete(string userRole) { - SetupConfigurationManager(false); - var user = SetupUser(userRole); - var context = new AuthorizationHandlerContext(_requirements, user, null); + TestHelpers.SetupConfigurationManager(_configurationManagerMock, false); + var claims = TestHelpers.SetupUser( + _userManagerMock, + _httpContextAccessor, + userRole); + + var context = new AuthorizationHandlerContext(_requirements, claims, null); await _sut.HandleAsync(context); Assert.True(context.HasSucceeded); @@ -48,30 +56,16 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy [InlineData(UserRoles.User, false)] public async Task ShouldRequireAdministratorIfStartupWizardComplete(string userRole, bool shouldSucceed) { - SetupConfigurationManager(true); - var user = SetupUser(userRole); - var context = new AuthorizationHandlerContext(_requirements, user, null); + TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); + var claims = TestHelpers.SetupUser( + _userManagerMock, + _httpContextAccessor, + userRole); + + var context = new AuthorizationHandlerContext(_requirements, claims, null); await _sut.HandleAsync(context); Assert.Equal(shouldSucceed, context.HasSucceeded); } - - private static ClaimsPrincipal SetupUser(string role) - { - var claims = new[] { new Claim(ClaimTypes.Role, role) }; - var identity = new ClaimsIdentity(claims); - return new ClaimsPrincipal(identity); - } - - private void SetupConfigurationManager(bool startupWizardCompleted) - { - var commonConfiguration = new BaseApplicationConfiguration - { - IsStartupWizardCompleted = startupWizardCompleted - }; - - _configurationManagerMock.Setup(c => c.CommonConfiguration) - .Returns(commonConfiguration); - } } } diff --git a/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs new file mode 100644 index 000000000..7150c90bb --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Jellyfin.Api.Auth.IgnoreParentalControlPolicy; +using Jellyfin.Api.Constants; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Auth.IgnoreSchedulePolicy +{ + public class IgnoreScheduleHandlerTests + { + private readonly Mock<IConfigurationManager> _configurationManagerMock; + private readonly List<IAuthorizationRequirement> _requirements; + private readonly IgnoreParentalControlHandler _sut; + private readonly Mock<IUserManager> _userManagerMock; + private readonly Mock<IHttpContextAccessor> _httpContextAccessor; + + /// <summary> + /// Globally disallow access. + /// </summary> + private readonly AccessSchedule[] _accessSchedules = { new AccessSchedule(DynamicDayOfWeek.Everyday, 0, 0, Guid.Empty) }; + + public IgnoreScheduleHandlerTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); + _requirements = new List<IAuthorizationRequirement> { new IgnoreParentalControlRequirement() }; + _userManagerMock = fixture.Freeze<Mock<IUserManager>>(); + _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); + + _sut = fixture.Create<IgnoreParentalControlHandler>(); + } + + [Theory] + [InlineData(UserRoles.Administrator, true)] + [InlineData(UserRoles.User, true)] + [InlineData(UserRoles.Guest, true)] + public async Task ShouldAllowScheduleCorrectly(string role, bool shouldSucceed) + { + TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); + var claims = TestHelpers.SetupUser( + _userManagerMock, + _httpContextAccessor, + role, + _accessSchedules); + + var context = new AuthorizationHandlerContext(_requirements, claims, null); + + await _sut.HandleAsync(context); + Assert.Equal(shouldSucceed, context.HasSucceeded); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs new file mode 100644 index 000000000..09ffa8468 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Jellyfin.Api.Auth.LocalAccessPolicy; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Auth.LocalAccessPolicy +{ + public class LocalAccessHandlerTests + { + private readonly Mock<IConfigurationManager> _configurationManagerMock; + private readonly List<IAuthorizationRequirement> _requirements; + private readonly LocalAccessHandler _sut; + private readonly Mock<IUserManager> _userManagerMock; + private readonly Mock<IHttpContextAccessor> _httpContextAccessor; + private readonly Mock<INetworkManager> _networkManagerMock; + + public LocalAccessHandlerTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); + _requirements = new List<IAuthorizationRequirement> { new LocalAccessRequirement() }; + _userManagerMock = fixture.Freeze<Mock<IUserManager>>(); + _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); + _networkManagerMock = fixture.Freeze<Mock<INetworkManager>>(); + + _sut = fixture.Create<LocalAccessHandler>(); + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + public async Task LocalAccessOnly(bool isInLocalNetwork, bool shouldSucceed) + { + _networkManagerMock + .Setup(n => n.IsInLocalNetwork(It.IsAny<string>())) + .Returns(isInLocalNetwork); + + TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); + var claims = TestHelpers.SetupUser( + _userManagerMock, + _httpContextAccessor, + UserRoles.User); + + var context = new AuthorizationHandlerContext(_requirements, claims, null); + await _sut.HandleAsync(context); + Assert.Equal(shouldSucceed, context.HasSucceeded); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs index cd05a8328..ffe88fcde 100644 --- a/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs @@ -1,20 +1,35 @@ using System.Collections.Generic; -using System.Security.Claims; using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Moq; using Xunit; namespace Jellyfin.Api.Tests.Auth.RequiresElevationPolicy { public class RequiresElevationHandlerTests { + private readonly Mock<IConfigurationManager> _configurationManagerMock; + private readonly List<IAuthorizationRequirement> _requirements; private readonly RequiresElevationHandler _sut; + private readonly Mock<IUserManager> _userManagerMock; + private readonly Mock<IHttpContextAccessor> _httpContextAccessor; public RequiresElevationHandlerTests() { - _sut = new RequiresElevationHandler(); + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); + _requirements = new List<IAuthorizationRequirement> { new RequiresElevationRequirement() }; + _userManagerMock = fixture.Freeze<Mock<IUserManager>>(); + _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); + + _sut = fixture.Create<RequiresElevationHandler>(); } [Theory] @@ -23,13 +38,13 @@ namespace Jellyfin.Api.Tests.Auth.RequiresElevationPolicy [InlineData(UserRoles.Guest, false)] public async Task ShouldHandleRolesCorrectly(string role, bool shouldSucceed) { - var requirements = new List<IAuthorizationRequirement> { new RequiresElevationRequirement() }; - - var claims = new[] { new Claim(ClaimTypes.Role, role) }; - var identity = new ClaimsIdentity(claims); - var user = new ClaimsPrincipal(identity); + TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); + var claims = TestHelpers.SetupUser( + _userManagerMock, + _httpContextAccessor, + role); - var context = new AuthorizationHandlerContext(requirements, user, null); + var context = new AuthorizationHandlerContext(_requirements, claims, null); await _sut.HandleAsync(context); Assert.Equal(shouldSucceed, context.HasSucceeded); diff --git a/tests/Jellyfin.Api.Tests/GetPathValueTests.cs b/tests/Jellyfin.Api.Tests/GetPathValueTests.cs deleted file mode 100644 index b01d1af1f..000000000 --- a/tests/Jellyfin.Api.Tests/GetPathValueTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -using MediaBrowser.Api; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; - -namespace Jellyfin.Api.Tests -{ - public class GetPathValueTests - { - [Theory] - [InlineData("https://localhost:8096/ScheduledTasks/1234/Triggers", "", 1, "1234")] - [InlineData("https://localhost:8096/emby/ScheduledTasks/1234/Triggers", "", 1, "1234")] - [InlineData("https://localhost:8096/mediabrowser/ScheduledTasks/1234/Triggers", "", 1, "1234")] - [InlineData("https://localhost:8096/jellyfin/2/ScheduledTasks/1234/Triggers", "jellyfin/2", 1, "1234")] - [InlineData("https://localhost:8096/jellyfin/2/emby/ScheduledTasks/1234/Triggers", "jellyfin/2", 1, "1234")] - [InlineData("https://localhost:8096/jellyfin/2/mediabrowser/ScheduledTasks/1234/Triggers", "jellyfin/2", 1, "1234")] - [InlineData("https://localhost:8096/JELLYFIN/2/ScheduledTasks/1234/Triggers", "jellyfin/2", 1, "1234")] - [InlineData("https://localhost:8096/JELLYFIN/2/Emby/ScheduledTasks/1234/Triggers", "jellyfin/2", 1, "1234")] - [InlineData("https://localhost:8096/JELLYFIN/2/MediaBrowser/ScheduledTasks/1234/Triggers", "jellyfin/2", 1, "1234")] - public void GetPathValueTest(string path, string baseUrl, int index, string value) - { - var reqMock = Mock.Of<IRequest>(x => x.PathInfo == path); - var conf = new ServerConfiguration() - { - BaseUrl = baseUrl - }; - - var confManagerMock = Mock.Of<IServerConfigurationManager>(x => x.Configuration == conf); - - var service = new BrandingService( - new NullLogger<BrandingService>(), - confManagerMock, - Mock.Of<IHttpResultFactory>()) - { - Request = reqMock - }; - - Assert.Equal(value, service.GetPathValue(index).ToString()); - } - } -} diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index 132050e7b..f77eba376 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -17,7 +17,7 @@ <PackageReference Include="AutoFixture.AutoMoq" Version="4.12.0" /> <PackageReference Include="AutoFixture.Xunit2" Version="4.12.0" /> <PackageReference Include="Microsoft.Extensions.Options" Version="3.1.6" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> @@ -33,8 +33,7 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="../../MediaBrowser.Api/MediaBrowser.Api.csproj" /> - <ProjectReference Include="../../Jellyfin.Api/Jellyfin.Api.csproj" /> + <ProjectReference Include="..\..\Jellyfin.Server\Jellyfin.Server.csproj" /> </ItemGroup> <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> diff --git a/tests/Jellyfin.Api.Tests/TestHelpers.cs b/tests/Jellyfin.Api.Tests/TestHelpers.cs new file mode 100644 index 000000000..a4dd4e409 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/TestHelpers.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using System.Security.Claims; +using Jellyfin.Api.Constants; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using Microsoft.AspNetCore.Http; +using Moq; +using AccessSchedule = Jellyfin.Data.Entities.AccessSchedule; + +namespace Jellyfin.Api.Tests +{ + public static class TestHelpers + { + public static ClaimsPrincipal SetupUser( + Mock<IUserManager> userManagerMock, + Mock<IHttpContextAccessor> httpContextAccessorMock, + string role, + IEnumerable<AccessSchedule>? accessSchedules = null) + { + var user = new User( + "jellyfin", + typeof(DefaultAuthenticationProvider).FullName, + typeof(DefaultPasswordResetProvider).FullName); + + // Set administrator flag. + user.SetPermission(PermissionKind.IsAdministrator, role.Equals(UserRoles.Administrator, StringComparison.OrdinalIgnoreCase)); + + // Add access schedules if set. + if (accessSchedules != null) + { + foreach (var accessSchedule in accessSchedules) + { + user.AccessSchedules.Add(accessSchedule); + } + } + + var claims = new[] + { + new Claim(ClaimTypes.Role, role), + new Claim(ClaimTypes.Name, "jellyfin"), + new Claim(InternalClaimTypes.UserId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)), + new Claim(InternalClaimTypes.DeviceId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)), + new Claim(InternalClaimTypes.Device, "test"), + new Claim(InternalClaimTypes.Client, "test"), + new Claim(InternalClaimTypes.Version, "test"), + new Claim(InternalClaimTypes.Token, "test"), + }; + + var identity = new ClaimsIdentity(claims); + + userManagerMock + .Setup(u => u.GetUserById(It.IsAny<Guid>())) + .Returns(user); + + httpContextAccessorMock + .Setup(h => h.HttpContext.Connection.RemoteIpAddress) + .Returns(new IPAddress(0)); + + return new ClaimsPrincipal(identity); + } + + public static void SetupConfigurationManager(in Mock<IConfigurationManager> configurationManagerMock, bool startupWizardCompleted) + { + var commonConfiguration = new BaseApplicationConfiguration + { + IsStartupWizardCompleted = startupWizardCompleted + }; + + configurationManagerMock + .Setup(c => c.CommonConfiguration) + .Returns(commonConfiguration); + } + } +} diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index 4cb1da994..746474044 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -13,7 +13,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index 18724f31c..1559f70ab 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -13,7 +13,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index 646ef00fd..e1a089547 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -19,7 +19,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index 1434cce96..0e9e91563 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -13,7 +13,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs index c145ddc9d..b4e6db8f3 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs @@ -9,17 +9,29 @@ namespace Jellyfin.Server.Implementations.Tests.Library [InlineData("/media/small.jpg", true)] [InlineData("/media/albumart.jpg", true)] [InlineData("/media/movie.sample.mp4", true)] + [InlineData("/media/movie/sample.mp4", true)] + [InlineData("/media/movie/sample/movie.mp4", true)] + [InlineData("/foo/sample/bar/baz.mkv", false)] + [InlineData("/media/movies/the sample/the sample.mkv", false)] + [InlineData("/media/movies/sampler.mkv", false)] [InlineData("/media/movies/#Recycle/test.txt", true)] [InlineData("/media/movies/#recycle/", true)] [InlineData("/media/movies/#recycle", true)] [InlineData("thumbs.db", true)] [InlineData(@"C:\media\movies\movie.avi", false)] - [InlineData("/media/.hiddendir/file.mp4", true)] + [InlineData("/media/.hiddendir/file.mp4", false)] [InlineData("/media/dir/.hiddenfile.mp4", true)] + [InlineData("/media/dir/._macjunk.mp4", true)] [InlineData("/volume1/video/Series/@eaDir", true)] [InlineData("/volume1/video/Series/@eaDir/file.txt", true)] [InlineData("/directory/@Recycle", true)] [InlineData("/directory/@Recycle/file.mp3", true)] + [InlineData("/media/movies/.@__thumb", true)] + [InlineData("/media/movies/.@__thumb/foo-bar-thumbnail.png", true)] + [InlineData("/media/music/Foo B.A.R./epic.flac", false)] + [InlineData("/media/music/Foo B.A.R", false)] + // This test is pending an upstream fix: https://github.com/dazinator/DotNet.Glob/issues/78 + // [InlineData("/media/music/Foo B.A.R.", false)] public void PathIgnored(string path, bool expected) { Assert.Equal(expected, IgnorePatterns.ShouldIgnore(path)); diff --git a/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs b/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs index 34698fe25..5d7f7765c 100644 --- a/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs +++ b/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs @@ -43,7 +43,7 @@ namespace MediaBrowser.Api.Tests // Assert response.EnsureSuccessStatusCode(); - Assert.Equal("text/css", response.Content.Headers.ContentType.ToString()); + Assert.Equal("text/css; charset=utf-8", response.Content.Headers.ContentType.ToString()); } } } diff --git a/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj b/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj index 8309faebd..a4ef10648 100644 --- a/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj +++ b/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj @@ -9,7 +9,7 @@ <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="3.1.6" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> @@ -17,7 +17,6 @@ <ItemGroup> <ProjectReference Include="..\..\Jellyfin.Server\Jellyfin.Server.csproj" /> - <ProjectReference Include="..\..\MediaBrowser.Api\MediaBrowser.Api.csproj" /> </ItemGroup> <!-- Code Analyzers --> |
