diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml index 13cc67528f..143873266d 100644 --- a/.ci/azure-pipelines.yml +++ b/.ci/azure-pipelines.yml @@ -19,9 +19,9 @@ jobs: vmImage: ubuntu-latest strategy: matrix: - release: + Release: BuildConfiguration: Release - debug: + Debug: BuildConfiguration: Debug maxParallel: 2 steps: @@ -31,32 +31,32 @@ jobs: persistCredentials: true - task: CmdLine@2 - displayName: "Check out web" + displayName: "Clone Web Client (Master, Release, or Tag)" condition: and(succeeded(), or(contains(variables['Build.SourceBranch'], 'release'), contains(variables['Build.SourceBranch'], 'master')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'BuildCompletion')) inputs: script: 'git clone --single-branch --branch $(Build.SourceBranchName) --depth=1 https://github.com/jellyfin/jellyfin-web.git $(Agent.TempDirectory)/jellyfin-web' - task: CmdLine@2 - displayName: "Check out web (PR)" + displayName: "Clone Web Client (PR)" condition: and(succeeded(), or(contains(variables['System.PullRequest.TargetBranch'], 'release'), contains(variables['System.PullRequest.TargetBranch'], 'master')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'PullRequest')) inputs: script: 'git clone --single-branch --branch $(System.PullRequest.TargetBranch) --depth 1 https://github.com/jellyfin/jellyfin-web.git $(Agent.TempDirectory)/jellyfin-web' - task: NodeTool@0 - displayName: 'Install Node.js' + displayName: 'Install Node' condition: and(succeeded(), or(contains(variables['System.PullRequest.TargetBranch'], 'release'), contains(variables['System.PullRequest.TargetBranch'], 'master'), contains(variables['Build.SourceBranch'], 'release'), contains(variables['Build.SourceBranch'], 'master')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'PullRequest', 'IndividualCI', 'BatchedCI', 'BuildCompletion')) inputs: versionSpec: '10.x' - task: CmdLine@2 - displayName: "Build Web UI" + displayName: "Build Web Client" condition: and(succeeded(), or(contains(variables['System.PullRequest.TargetBranch'], 'release'), contains(variables['System.PullRequest.TargetBranch'], 'master'), contains(variables['Build.SourceBranch'], 'release'), contains(variables['Build.SourceBranch'], 'master')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'PullRequest', 'IndividualCI', 'BatchedCI', 'BuildCompletion')) inputs: script: yarn install workingDirectory: $(Agent.TempDirectory)/jellyfin-web - task: CopyFiles@2 - displayName: Copy the web UI + displayName: 'Copy Web Client' condition: and(succeeded(), or(contains(variables['System.PullRequest.TargetBranch'], 'release'), contains(variables['System.PullRequest.TargetBranch'], 'master'), contains(variables['Build.SourceBranch'], 'release'), contains(variables['Build.SourceBranch'], 'master')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'PullRequest', 'IndividualCI', 'BatchedCI', 'BuildCompletion')) inputs: sourceFolder: $(Agent.TempDirectory)/jellyfin-web/dist # Optional @@ -66,8 +66,14 @@ jobs: overWrite: true # Optional flattenFolders: false # Optional + - task: UseDotNet@2 + displayName: 'Update DotNet' + inputs: + packageType: sdk + version: 3.1.100 + - task: DotNetCoreCLI@2 - displayName: Publish + displayName: 'Publish Server' inputs: command: publish publishWebProjects: false @@ -135,62 +141,20 @@ jobs: !**\obj\** !**\xunit.runner.visualstudio.testadapter.dll !**\xunit.runner.visualstudio.dotnetcore.testadapter.dll - #testPlan: # Required when testSelector == TestPlan - #testSuite: # Required when testSelector == TestPlan - #testConfiguration: # Required when testSelector == TestPlan - #tcmTestRun: '$(test.RunId)' # Optional searchFolder: '$(System.DefaultWorkingDirectory)' - #testFiltercriteria: # Optional - #runOnlyImpactedTests: False # Optional - #runAllTestsAfterXBuilds: '50' # Optional - #uiTests: false # Optional - #vstestLocationMethod: 'version' # Optional. Options: version, location - #vsTestVersion: 'latest' # Optional. Options: latest, 16.0, 15.0, 14.0, toolsInstaller - #vstestLocation: # Optional - #runSettingsFile: # Optional - #overrideTestrunParameters: # Optional - #pathtoCustomTestAdapters: # Optional runInParallel: True # Optional runTestsInIsolation: True # Optional codeCoverageEnabled: True # Optional - #otherConsoleOptions: # Optional - #distributionBatchType: 'basedOnTestCases' # Optional. Options: basedOnTestCases, basedOnExecutionTime, basedOnAssembly - #batchingBasedOnAgentsOption: 'autoBatchSize' # Optional. Options: autoBatchSize, customBatchSize - #customBatchSizeValue: '10' # Required when distributionBatchType == BasedOnTestCases && BatchingBasedOnAgentsOption == CustomBatchSize - #batchingBasedOnExecutionTimeOption: 'autoBatchSize' # Optional. Options: autoBatchSize, customTimeBatchSize - #customRunTimePerBatchValue: '60' # Required when distributionBatchType == BasedOnExecutionTime && BatchingBasedOnExecutionTimeOption == CustomTimeBatchSize - #dontDistribute: False # Optional - #testRunTitle: # Optional - #platform: # Optional configuration: 'Debug' # Optional publishRunAttachments: true # Optional - #diagnosticsEnabled: false # Optional - #collectDumpOn: 'onAbortOnly' # Optional. Options: onAbortOnly, always, never - #rerunFailedTests: False # Optional - #rerunType: 'basedOnTestFailurePercentage' # Optional. Options: basedOnTestFailurePercentage, basedOnTestFailureCount - #rerunFailedThreshold: '30' # Optional - #rerunFailedTestCasesMaxLimit: '5' # Optional - #rerunMaxAttempts: '3' # Optional - - # - task: PublishTestResults@2 - # inputs: - # testResultsFormat: 'VSTest' # Options: JUnit, NUnit, VSTest, xUnit, cTest - # testResultsFiles: '**/*.trx' - # #searchFolder: '$(System.DefaultWorkingDirectory)' # Optional - # mergeTestResults: true # Optional - # #failTaskOnFailedTests: false # Optional - # #testRunTitle: # Optional - # #buildPlatform: # Optional - # #buildConfiguration: # Optional - # #publishRunAttachments: true # Optional - job: main_build_win - displayName: Main Build Windows + displayName: Publish Windows pool: vmImage: windows-latest strategy: matrix: - release: + Release: BuildConfiguration: Release maxParallel: 2 steps: @@ -200,32 +164,32 @@ jobs: persistCredentials: true - task: CmdLine@2 - displayName: "Check out web (master, release or tag)" + displayName: "Clone Web Client (Master, Release, or Tag)" condition: and(succeeded(), or(contains(variables['Build.SourceBranch'], 'release'), contains(variables['Build.SourceBranch'], 'master'), contains(variables['Build.SourceBranch'], 'tag')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'BuildCompletion')) inputs: script: 'git clone --single-branch --branch $(Build.SourceBranchName) --depth=1 https://github.com/jellyfin/jellyfin-web.git $(Agent.TempDirectory)/jellyfin-web' - task: CmdLine@2 - displayName: "Check out web (PR)" + displayName: "Clone Web Client (PR)" condition: and(succeeded(), or(contains(variables['System.PullRequest.TargetBranch'], 'release'), contains(variables['System.PullRequest.TargetBranch'], 'master')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'PullRequest')) inputs: script: 'git clone --single-branch --branch $(System.PullRequest.TargetBranch) --depth 1 https://github.com/jellyfin/jellyfin-web.git $(Agent.TempDirectory)/jellyfin-web' - task: NodeTool@0 - displayName: 'Install Node.js' + displayName: 'Install Node' condition: and(succeeded(), or(contains(variables['System.PullRequest.TargetBranch'], 'release'), contains(variables['System.PullRequest.TargetBranch'], 'master'), contains(variables['Build.SourceBranch'], 'release'), contains(variables['Build.SourceBranch'], 'master')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'PullRequest', 'IndividualCI', 'BatchedCI', 'BuildCompletion')) inputs: versionSpec: '10.x' - task: CmdLine@2 - displayName: "Build Web UI" + displayName: "Build Web Client" condition: and(succeeded(), or(contains(variables['System.PullRequest.TargetBranch'], 'release'), contains(variables['System.PullRequest.TargetBranch'], 'master'), contains(variables['Build.SourceBranch'], 'release'), contains(variables['Build.SourceBranch'], 'master')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'PullRequest', 'IndividualCI', 'BatchedCI', 'BuildCompletion')) inputs: script: yarn install workingDirectory: $(Agent.TempDirectory)/jellyfin-web - task: CopyFiles@2 - displayName: Copy the web UI + displayName: 'Copy Web Client' condition: and(succeeded(), or(contains(variables['System.PullRequest.TargetBranch'], 'release'), contains(variables['System.PullRequest.TargetBranch'], 'master'), contains(variables['Build.SourceBranch'], 'release'), contains(variables['Build.SourceBranch'], 'master')) ,eq(variables['BuildConfiguration'], 'Release'), in(variables['Build.Reason'], 'PullRequest', 'IndividualCI', 'BatchedCI', 'BuildCompletion')) inputs: sourceFolder: $(Agent.TempDirectory)/jellyfin-web/dist # Optional @@ -236,25 +200,21 @@ jobs: flattenFolders: false # Optional - task: CmdLine@2 - displayName: Clone the UX repository + displayName: 'Clone UX Repository' inputs: script: git clone --depth=1 https://github.com/jellyfin/jellyfin-ux $(Agent.TempDirectory)\jellyfin-ux - task: PowerShell@2 - displayName: Build the NSIS Installer + displayName: 'Build NSIS Installer' inputs: targetType: 'filePath' # Optional. Options: filePath, inline filePath: ./deployment/windows/build-jellyfin.ps1 # Required when targetType == FilePath arguments: -InstallFFMPEG -InstallNSSM -MakeNSIS -InstallTrayApp -UXLocation $(Agent.TempDirectory)\jellyfin-ux -InstallLocation $(build.artifactstagingdirectory) - #script: '# Write your PowerShell commands here.Write-Host Hello World' # Required when targetType == Inline errorActionPreference: 'stop' # Optional. Options: stop, continue, silentlyContinue - #failOnStderr: false # Optional - #ignoreLASTEXITCODE: false # Optional - #pwsh: false # Optional workingDirectory: $(Build.SourcesDirectory) # Optional - task: CopyFiles@2 - displayName: Copy the NSIS Installer to the artifact directory + displayName: 'Copy NSIS Installer' inputs: sourceFolder: $(Build.SourcesDirectory)/deployment/windows/ # Optional contents: 'jellyfin*.exe' @@ -264,7 +224,7 @@ jobs: flattenFolders: true # Optional - task: PublishPipelineArtifact@0 - displayName: 'Publish Setup Artifact' + displayName: 'Publish Artifact Setup' condition: and(eq(variables['BuildConfiguration'], 'Release'), succeeded()) inputs: targetPath: '$(build.artifactstagingdirectory)/setup' @@ -275,7 +235,8 @@ jobs: pool: vmImage: ubuntu-latest dependsOn: main_build - condition: and(succeeded(), variables['System.PullRequest.PullRequestNumber']) # Only execute if the pullrequest numer is defined. (So not for normal CI builds) + # only execute for pull requests + condition: and(succeeded(), variables['System.PullRequest.PullRequestNumber']) strategy: matrix: Naming: @@ -293,24 +254,23 @@ jobs: maxParallel: 2 steps: - checkout: none + + - task: UseDotNet@2 + displayName: 'Update DotNet' + inputs: + packageType: sdk + version: 3.1.100 - task: DownloadPipelineArtifact@2 - displayName: Download the New Assembly Build Artifact + displayName: 'Download New Assembly Build Artifact' inputs: source: 'current' # Options: current, specific - #preferTriggeringPipeline: false # Optional - #tags: # Optional artifact: '$(NugetPackageName)' # Optional - #patterns: '**' # Optional path: '$(System.ArtifactsDirectory)/new-artifacts' - #project: # Required when source == Specific - #pipeline: # Required when source == Specific runVersion: 'latest' # Required when source == Specific. Options: latest, latestFromBranch, specific - #runBranch: 'refs/heads/master' # Required when source == Specific && runVersion == LatestFromBranch - #runId: # Required when source == Specific && runVersion == Specific - task: CopyFiles@2 - displayName: Copy New Assembly to new-release folder + displayName: 'Copy New Assembly Build Artifact' inputs: sourceFolder: $(System.ArtifactsDirectory)/new-artifacts # Optional contents: '**/*.dll' @@ -320,22 +280,18 @@ jobs: flattenFolders: true # Optional - task: DownloadPipelineArtifact@2 - displayName: Download the Reference Assembly Build Artifact + displayName: 'Download Reference Assembly Build Artifact' inputs: source: 'specific' # Options: current, specific - #preferTriggeringPipeline: false # Optional - #tags: # Optional artifact: '$(NugetPackageName)' # Optional - #patterns: '**' # Optional path: '$(System.ArtifactsDirectory)/current-artifacts' project: '$(System.TeamProjectId)' # Required when source == Specific pipeline: '$(System.DefinitionId)' # Required when source == Specific runVersion: 'latestFromBranch' # Required when source == Specific. Options: latest, latestFromBranch, specific runBranch: 'refs/heads/$(System.PullRequest.TargetBranch)' # Required when source == Specific && runVersion == LatestFromBranch - #runId: # Required when source == Specific && runVersion == Specific - task: CopyFiles@2 - displayName: Copy Reference Assembly to current-release folder + displayName: 'Copy Reference Assembly Build Artifact' inputs: sourceFolder: $(System.ArtifactsDirectory)/current-artifacts # Optional contents: '**/*.dll' @@ -345,27 +301,24 @@ jobs: flattenFolders: true # Optional - task: DownloadGitHubRelease@0 - displayName: Download ABI compatibility check tool from GitHub + displayName: 'Download ABI Compatibility Check Tool' inputs: connection: Jellyfin Release Download userRepository: EraYaN/dotnet-compatibility defaultVersionType: 'latest' # Options: latest, specificVersion, specificTag - #version: # Required when defaultVersionType != Latest itemPattern: '**-ci.zip' # Optional downloadPath: '$(System.ArtifactsDirectory)' - task: ExtractFiles@1 - displayName: Extract ABI compatibility check tool + displayName: 'Extract ABI Compatibility Check Tool' inputs: archiveFilePatterns: '$(System.ArtifactsDirectory)/*-ci.zip' destinationFolder: $(System.ArtifactsDirectory)/tools cleanDestinationFolder: true + # The `--warnings-only` switch will swallow the return code and not emit any errors. - task: CmdLine@2 - displayName: Execute ABI compatibility check tool + displayName: 'Execute ABI Compatibility Check Tool' inputs: - script: 'dotnet tools/CompatibilityCheckerCoreCLI.dll current-release/$(AssemblyFileName) new-release/$(AssemblyFileName) --azure-pipelines' + script: 'dotnet tools/CompatibilityCheckerCLI.dll current-release/$(AssemblyFileName) new-release/$(AssemblyFileName) --azure-pipelines --warnings-only' workingDirectory: $(System.ArtifactsDirectory) # Optional - #failOnStderr: false # Optional - - diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 67bc0cd2b1..a179c1b150 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -103,14 +103,11 @@ using MediaBrowser.Providers.Subtitles; using MediaBrowser.Providers.TV.TheTVDB; using MediaBrowser.WebDashboard.Api; using MediaBrowser.XbmcMetadata.Providers; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.OpenApi.Models; using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; namespace Emby.Server.Implementations @@ -878,6 +875,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(typeof(IResourceFileManager), typeof(ResourceFileManager)); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(typeof(IAttachmentExtractor), typeof(MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor)); + _displayPreferencesRepository.Initialize(); var userDataRepo = new SqliteUserDataRepository(LoggerFactory.CreateLogger(), ApplicationPaths); @@ -1478,7 +1477,7 @@ namespace Emby.Server.Implementations /// /// The IPv6 address. /// The IPv6 address without the scope id. - private string RemoveScopeId(string address) + private ReadOnlySpan RemoveScopeId(ReadOnlySpan address) { var index = address.IndexOf('%'); if (index == -1) @@ -1486,33 +1485,50 @@ namespace Emby.Server.Implementations return address; } - return address.Substring(0, index); + return address.Slice(0, index); } + /// public string GetLocalApiUrl(IPAddress ipAddress) { if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) { var str = RemoveScopeId(ipAddress.ToString()); + Span span = new char[str.Length + 2]; + span[0] = '['; + str.CopyTo(span.Slice(1)); + span[^1] = ']'; - return GetLocalApiUrl("[" + str + "]"); + return GetLocalApiUrl(span); } return GetLocalApiUrl(ipAddress.ToString()); } - public string GetLocalApiUrl(string host) + /// + public string GetLocalApiUrl(ReadOnlySpan host) { + var url = new StringBuilder(64); if (EnableHttps) { - return string.Format("https://{0}:{1}", - host, - HttpsPort.ToString(CultureInfo.InvariantCulture)); + url.Append("https://"); + } + else + { + url.Append("http://"); } - return string.Format("http://{0}:{1}", - host, - HttpPort.ToString(CultureInfo.InvariantCulture)); + url.Append(host) + .Append(':') + .Append(HttpPort); + + string baseUrl = ServerConfigurationManager.Configuration.BaseUrl; + if (baseUrl.Length != 0) + { + url.Append('/').Append(baseUrl); + } + + return url.ToString(); } public Task> GetLocalIpAddresses(CancellationToken cancellationToken) diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index ef1fe07c16..c514846e58 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -49,6 +49,21 @@ namespace Emby.Server.Implementations.Data private readonly TypeMapper _typeMapper; private readonly JsonSerializerOptions _jsonOptions; + static SqliteItemRepository() + { + var queryPrefixText = new StringBuilder(); + queryPrefixText.Append("insert into mediaattachments ("); + foreach (var column in _mediaAttachmentSaveColumns) + { + queryPrefixText.Append(column) + .Append(','); + } + + queryPrefixText.Length -= 1; + queryPrefixText.Append(") values "); + _mediaAttachmentInsertPrefix = queryPrefixText.ToString(); + } + /// /// Initializes a new instance of the class. /// @@ -92,6 +107,8 @@ namespace Emby.Server.Implementations.Data { const string CreateMediaStreamsTableCommand = "create table if not exists mediastreams (ItemId GUID, StreamIndex INT, StreamType TEXT, Codec TEXT, Language TEXT, ChannelLayout TEXT, Profile TEXT, AspectRatio TEXT, Path TEXT, IsInterlaced BIT, BitRate INT NULL, Channels INT NULL, SampleRate INT NULL, IsDefault BIT, IsForced BIT, IsExternal BIT, Height INT NULL, Width INT NULL, AverageFrameRate FLOAT NULL, RealFrameRate FLOAT NULL, Level FLOAT NULL, PixelFormat TEXT, BitDepth INT NULL, IsAnamorphic BIT NULL, RefFrames INT NULL, CodecTag TEXT NULL, Comment TEXT NULL, NalLengthSize TEXT NULL, IsAvc BIT NULL, Title TEXT NULL, TimeBase TEXT NULL, CodecTimeBase TEXT NULL, ColorPrimaries TEXT NULL, ColorSpace TEXT NULL, ColorTransfer TEXT NULL, PRIMARY KEY (ItemId, StreamIndex))"; + const string CreateMediaAttachmentsTableCommand + = "create table if not exists mediaattachments (ItemId GUID, AttachmentIndex INT, Codec TEXT, CodecTag TEXT NULL, Comment TEXT NULL, Filename TEXT NULL, MIMEType TEXT NULL, PRIMARY KEY (ItemId, AttachmentIndex))"; string[] queries = { @@ -114,6 +131,7 @@ namespace Emby.Server.Implementations.Data "create table if not exists " + ChaptersTableName + " (ItemId GUID, ChapterIndex INT NOT NULL, StartPositionTicks BIGINT NOT NULL, Name TEXT, ImagePath TEXT, PRIMARY KEY (ItemId, ChapterIndex))", CreateMediaStreamsTableCommand, + CreateMediaAttachmentsTableCommand, "pragma shrink_memory" }; @@ -421,6 +439,19 @@ namespace Emby.Server.Implementations.Data "ColorTransfer" }; + private static readonly string[] _mediaAttachmentSaveColumns = + { + "ItemId", + "AttachmentIndex", + "Codec", + "CodecTag", + "Comment", + "Filename", + "MIMEType" + }; + + private static readonly string _mediaAttachmentInsertPrefix; + private static string GetSaveItemCommandText() { var saveColumns = new [] @@ -6136,5 +6167,175 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type return item; } + + public List GetMediaAttachments(MediaAttachmentQuery query) + { + CheckDisposed(); + + if (query == null) + { + throw new ArgumentNullException(nameof(query)); + } + + var cmdText = "select " + + string.Join(",", _mediaAttachmentSaveColumns) + + " from mediaattachments where" + + " ItemId=@ItemId"; + + if (query.Index.HasValue) + { + cmdText += " AND AttachmentIndex=@AttachmentIndex"; + } + + cmdText += " order by AttachmentIndex ASC"; + + var list = new List(); + using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, cmdText)) + { + statement.TryBind("@ItemId", query.ItemId.ToByteArray()); + + if (query.Index.HasValue) + { + statement.TryBind("@AttachmentIndex", query.Index.Value); + } + + foreach (var row in statement.ExecuteQuery()) + { + list.Add(GetMediaAttachment(row)); + } + } + + return list; + } + + public void SaveMediaAttachments( + Guid id, + IReadOnlyList attachments, + CancellationToken cancellationToken) + { + CheckDisposed(); + if (id == Guid.Empty) + { + throw new ArgumentException(nameof(id)); + } + + if (attachments == null) + { + throw new ArgumentNullException(nameof(attachments)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + using (var connection = GetConnection()) + { + connection.RunInTransaction(db => + { + var itemIdBlob = id.ToByteArray(); + + db.Execute("delete from mediaattachments where ItemId=@ItemId", itemIdBlob); + + InsertMediaAttachments(itemIdBlob, attachments, db, cancellationToken); + + }, TransactionMode); + } + } + + private void InsertMediaAttachments( + byte[] idBlob, + IReadOnlyList attachments, + IDatabaseConnection db, + CancellationToken cancellationToken) + { + const int InsertAtOnce = 10; + + 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++) + { + var index = i.ToString(CultureInfo.InvariantCulture); + insertText.Append("(@ItemId, "); + + foreach (var column in _mediaAttachmentSaveColumns.Skip(1)) + { + insertText.Append("@" + column + index + ","); + } + + insertText.Length -= 1; + + insertText.Append("),"); + } + + insertText.Length--; + + cancellationToken.ThrowIfCancellationRequested(); + + using (var statement = PrepareStatement(db, insertText.ToString())) + { + statement.TryBind("@ItemId", idBlob); + + for (var i = startIndex; i < endIndex; i++) + { + var index = i.ToString(CultureInfo.InvariantCulture); + + var attachment = attachments[i]; + + statement.TryBind("@AttachmentIndex" + index, attachment.Index); + statement.TryBind("@Codec" + index, attachment.Codec); + statement.TryBind("@CodecTag" + index, attachment.CodecTag); + statement.TryBind("@Comment" + index, attachment.Comment); + statement.TryBind("@FileName" + index, attachment.FileName); + statement.TryBind("@MimeType" + index, attachment.MimeType); + } + + statement.Reset(); + statement.MoveNext(); + } + } + } + + /// + /// Gets the attachment. + /// + /// The reader. + /// MediaAttachment + private MediaAttachment GetMediaAttachment(IReadOnlyList reader) + { + var item = new MediaAttachment + { + Index = reader[1].ToInt() + }; + + if (reader[2].SQLiteType != SQLiteType.Null) + { + item.Codec = reader[2].ToString(); + } + + if (reader[2].SQLiteType != SQLiteType.Null) + { + item.CodecTag = reader[3].ToString(); + } + + if (reader[4].SQLiteType != SQLiteType.Null) + { + item.Comment = reader[4].ToString(); + } + + if (reader[6].SQLiteType != SQLiteType.Null) + { + item.FileName = reader[5].ToString(); + } + + if (reader[6].SQLiteType != SQLiteType.Null) + { + item.MimeType = reader[6].ToString(); + } + + return item; + } } } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 442fbabd17..9568f62dff 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -109,7 +109,7 @@ namespace Emby.Server.Implementations.IO } try { - return Path.Combine(Path.GetFullPath(folderPath), filePath); + return Path.GetFullPath(Path.Combine(folderPath, filePath)); } catch (ArgumentException) { diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 22193c997e..ba1564d1f8 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -130,6 +130,21 @@ namespace Emby.Server.Implementations.Library return streams; } + /// + public List GetMediaAttachments(MediaAttachmentQuery query) + { + return _itemRepo.GetMediaAttachments(query); + } + + /// + public List GetMediaAttachments(Guid itemId) + { + return GetMediaAttachments(new MediaAttachmentQuery + { + ItemId = itemId + }); + } + public async Task> GetPlayackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken) { var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 9805992be9..e1dce82ffe 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -19,10 +19,10 @@ "HeaderCameraUploads": "Photos transférées", "HeaderContinueWatching": "Continuer à regarder", "HeaderFavoriteAlbums": "Albums favoris", - "HeaderFavoriteArtists": "Artistes favoris", + "HeaderFavoriteArtists": "Artistes préférés", "HeaderFavoriteEpisodes": "Épisodes favoris", "HeaderFavoriteShows": "Séries favorites", - "HeaderFavoriteSongs": "Chansons favorites", + "HeaderFavoriteSongs": "Chansons préférées", "HeaderLiveTV": "TV en direct", "HeaderNextUp": "À suivre", "HeaderRecordingGroups": "Groupes d'enregistrements", diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index 0967ef424b..ef8d988c87 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -1 +1,96 @@ -{} +{ + "HeaderLiveTV": "TV ao Vivo", + "Collections": "Colecções", + "Books": "Livros", + "Artists": "Artistas", + "Albums": "Álbuns", + "HeaderNextUp": "A Seguir", + "HeaderFavoriteSongs": "Músicas Favoritas", + "HeaderFavoriteArtists": "Artistas Favoritos", + "HeaderFavoriteAlbums": "Álbuns Favoritos", + "HeaderFavoriteEpisodes": "Episódios Favoritos", + "HeaderFavoriteShows": "Séries Favoritas", + "HeaderContinueWatching": "Continuar a Ver", + "HeaderAlbumArtists": "Artistas do Álbum", + "Genres": "Géneros", + "Folders": "Pastas", + "Favorites": "Favoritos", + "Channels": "Canais", + "UserDownloadingItemWithValues": "{0} está a transferir {1}", + "VersionNumber": "Versão {0}", + "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua biblioteca multimédia", + "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", + "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", + "UserPolicyUpdatedWithName": "A política do utilizador {0} foi alterada", + "UserPasswordChangedWithName": "A palavra-passe do utilizador {0} foi alterada", + "UserOnlineFromDevice": "{0} ligou-se a partir de {1}", + "UserOfflineFromDevice": "{0} desligou-se a partir de {1}", + "UserLockedOutWithName": "Utilizador {0} bloqueado", + "UserDeletedWithName": "Utilizador {0} removido", + "UserCreatedWithName": "Utilizador {0} criado", + "User": "Utilizador", + "TvShows": "Programas", + "System": "Sistema", + "SubtitlesDownloadedForItem": "Legendas transferidas para {0}", + "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente dentro de momentos.", + "ServerNameNeedsToBeRestarted": "{0} necessita ser reiniciado", + "ScheduledTaskStartedWithName": "{0} iniciou", + "ScheduledTaskFailedWithName": "{0} falhou", + "ProviderValue": "Fornecedor: {0}", + "PluginUpdatedWithName": "{0} foi actualizado", + "PluginUninstalledWithName": "{0} foi desinstalado", + "PluginInstalledWithName": "{0} foi instalado", + "Plugin": "Extensão", + "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada", + "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", + "NotificationOptionUserLockedOut": "Utilizador bloqueado", + "NotificationOptionTaskFailed": "Falha em tarefa agendada", + "NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor", + "NotificationOptionPluginUpdateInstalled": "Extensão actualizada", + "NotificationOptionPluginUninstalled": "Extensão desinstalada", + "NotificationOptionPluginInstalled": "Extensão instalada", + "NotificationOptionPluginError": "Falha na extensão", + "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", + "NotificationOptionInstallationFailed": "Falha de instalação", + "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada", + "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", + "NotificationOptionAudioPlayback": "Reprodução Iniciada", + "NotificationOptionApplicationUpdateInstalled": "A actualização da aplicação foi instalada", + "NotificationOptionApplicationUpdateAvailable": "Uma actualização da aplicação está disponível", + "NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para transferência.", + "NameSeasonUnknown": "Temporada Desconhecida", + "NameSeasonNumber": "Temporada {0}", + "NameInstallFailed": "Falha na instalação de {0}", + "MusicVideos": "Videoclips", + "Music": "Música", + "MixedContent": "Conteúdo Misto", + "MessageServerConfigurationUpdated": "A configuração do servidor foi actualizada", + "MessageNamedServerConfigurationUpdatedWithValue": "Configurações do servidor na secção {0} foram atualizadas", + "MessageApplicationUpdatedTo": "O servidor Jellyfin foi actualizado para a versão {0}", + "MessageApplicationUpdated": "O servidor Jellyfin foi actualizado", + "Latest": "Mais Recente", + "LabelRunningTimeValue": "Duração: {0}", + "LabelIpAddressValue": "Endereço IP: {0}", + "ItemRemovedWithName": "{0} foi removido da biblioteca", + "ItemAddedWithName": "{0} foi adicionado à biblioteca", + "Inherit": "Herdar", + "HomeVideos": "Vídeos Caseiros", + "HeaderRecordingGroups": "Grupos de Gravação", + "ValueSpecialEpisodeName": "Especial - {0}", + "Sync": "Sincronização", + "Songs": "Músicas", + "Shows": "Séries", + "Playlists": "Listas de Reprodução", + "Photos": "Fotografias", + "Movies": "Filmes", + "HeaderCameraUploads": "Envios a partir da câmara", + "FailedLoginAttemptWithUserName": "Tentativa de ligação a partir de {0} falhou", + "DeviceOnlineWithName": "{0} ligou-se", + "DeviceOfflineWithName": "{0} desligou-se", + "ChapterNameValue": "Capítulo {0}", + "CameraImageUploadedFrom": "Uma nova imagem de câmara foi enviada a partir de {0}", + "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", + "Application": "Aplicação", + "AppDeviceValues": "Aplicação {0}, Dispositivo: {1}" +} diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 1014c8c56b..afc9b8f3da 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -96,7 +96,6 @@ namespace Jellyfin.Api.Controllers public StartupUserDto GetFirstUser() { var user = _userManager.Users.First(); - return new StartupUserDto { Name = user.Name, diff --git a/MediaBrowser.Api/Attachments/AttachmentService.cs b/MediaBrowser.Api/Attachments/AttachmentService.cs new file mode 100644 index 0000000000..1632ca1b06 --- /dev/null +++ b/MediaBrowser.Api/Attachments/AttachmentService.cs @@ -0,0 +1,63 @@ +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 logger, + IServerConfigurationManager serverConfigurationManager, + IHttpResultFactory httpResultFactory, + ILibraryManager libraryManager, + IAttachmentExtractor attachmentExtractor) + : base(logger, serverConfigurationManager, httpResultFactory) + { + _libraryManager = libraryManager; + _attachmentExtractor = attachmentExtractor; + } + + public async Task 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/Playback/MediaInfoService.cs b/MediaBrowser.Api/Playback/MediaInfoService.cs index 187e664756..24f6b6408b 100644 --- a/MediaBrowser.Api/Playback/MediaInfoService.cs +++ b/MediaBrowser.Api/Playback/MediaInfoService.cs @@ -572,6 +572,16 @@ namespace MediaBrowser.Api.Playback } } } + + foreach (var attachment in mediaSource.MediaAttachments) + { + attachment.DeliveryUrl = string.Format( + CultureInfo.InvariantCulture, + "/Videos/{0}/{1}/Attachments/{2}", + item.Id, + mediaSource.Id, + attachment.Index); + } } private long? GetMaxBitrate(long? clientMaxBitrate, User user) diff --git a/MediaBrowser.Common/Json/Converters/GuidConverter.cs b/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs similarity index 91% rename from MediaBrowser.Common/Json/Converters/GuidConverter.cs rename to MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs index 3081e12eee..d35a761f35 100644 --- a/MediaBrowser.Common/Json/Converters/GuidConverter.cs +++ b/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Common.Json.Converters /// /// Converts a GUID object or value to/from JSON. /// - public class GuidConverter : JsonConverter + public class JsonGuidConverter : JsonConverter { /// public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs b/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs new file mode 100644 index 0000000000..fe5dd6cd4d --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs @@ -0,0 +1,53 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// + /// Converts a GUID object or value to/from JSON. + /// + public class JsonInt32Converter : JsonConverter + { + /// + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + static void ThrowFormatException() => throw new FormatException("Invalid format for an integer."); + ReadOnlySpan span = stackalloc byte[0]; + + if (reader.HasValueSequence) + { + long sequenceLength = reader.ValueSequence.Length; + Span stackSpan = stackalloc byte[(int)sequenceLength]; + reader.ValueSequence.CopyTo(stackSpan); + span = stackSpan; + } + else + { + span = reader.ValueSpan; + } + + if (!Utf8Parser.TryParse(span, out int number, out _)) + { + ThrowFormatException(); + } + + return number; + } + + /// + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + static void ThrowInvalidOperationException() => throw new InvalidOperationException(); + Span span = stackalloc byte[16]; + if (Utf8Formatter.TryFormat(value, span, out int bytesWritten)) + { + writer.WriteStringValue(span.Slice(0, bytesWritten)); + } + + ThrowInvalidOperationException(); + } + } +} diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index 4ba0d5a1a5..4a6ee0a793 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -21,7 +21,7 @@ namespace MediaBrowser.Common.Json WriteIndented = false }; - options.Converters.Add(new GuidConverter()); + options.Converters.Add(new JsonGuidConverter()); options.Converters.Add(new JsonStringEnumConverter()); return options; diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 1fd706857a..cba2c9ddac 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1098,6 +1098,7 @@ namespace MediaBrowser.Controller.Entities Id = item.Id.ToString("N", CultureInfo.InvariantCulture), Protocol = protocol ?? MediaProtocol.File, MediaStreams = MediaSourceManager.GetMediaStreams(item.Id), + MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id), Name = GetMediaSourceName(item), Path = enablePathSubstitution ? GetMappedPath(item, item.Path, protocol) : item.Path, RunTimeTicks = item.RunTimeTicks, diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index b3c56bdd5f..25f0905eb8 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -71,13 +71,15 @@ namespace MediaBrowser.Controller /// /// Gets the local API URL. /// - /// The host. - /// System.String. - string GetLocalApiUrl(string host); + /// The hostname. + /// The local API URL. + string GetLocalApiUrl(ReadOnlySpan hostname); /// /// Gets the local API URL. /// + /// The IP address. + /// The local API URL. string GetLocalApiUrl(IPAddress address); void LaunchUrl(string url); diff --git a/MediaBrowser.Controller/Library/IMediaSourceManager.cs b/MediaBrowser.Controller/Library/IMediaSourceManager.cs index fbae4edb09..09e6fda885 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs @@ -38,6 +38,20 @@ namespace MediaBrowser.Controller.Library /// IEnumerable<MediaStream>. List GetMediaStreams(MediaStreamQuery query); + /// + /// Gets the media attachments. + /// + /// The item identifier. + /// IEnumerable<MediaAttachment>. + List GetMediaAttachments(Guid itemId); + + /// + /// Gets the media attachments. + /// + /// The query. + /// IEnumerable<MediaAttachment>. + List GetMediaAttachments(MediaAttachmentQuery query); + /// /// Gets the playack media sources. /// diff --git a/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs new file mode 100644 index 0000000000..7c7e84de64 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs @@ -0,0 +1,17 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Controller.MediaEncoding +{ + public interface IAttachmentExtractor + { + Task<(MediaAttachment attachment, Stream stream)> GetAttachment( + BaseItem item, + string mediaSourceId, + int attachmentStreamIndex, + CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 47e0f34532..5a5b7f58f0 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -78,6 +78,21 @@ namespace MediaBrowser.Controller.Persistence /// The cancellation token. void SaveMediaStreams(Guid id, List streams, CancellationToken cancellationToken); + /// + /// Gets the media attachments. + /// + /// The query. + /// IEnumerable{MediaAttachment}. + List GetMediaAttachments(MediaAttachmentQuery query); + + /// + /// Saves the media attachments. + /// + /// The identifier. + /// The attachments. + /// The cancellation token. + void SaveMediaAttachments(Guid id, IReadOnlyList attachments, CancellationToken cancellationToken); + /// /// Gets the item ids. /// diff --git a/MediaBrowser.Controller/Persistence/MediaAttachmentQuery.cs b/MediaBrowser.Controller/Persistence/MediaAttachmentQuery.cs new file mode 100644 index 0000000000..91ab34aab6 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/MediaAttachmentQuery.cs @@ -0,0 +1,20 @@ +using System; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Controller.Persistence +{ + public class MediaAttachmentQuery + { + /// + /// Gets or sets the index. + /// + /// The index. + public int? Index { get; set; } + + /// + /// Gets or sets the item identifier. + /// + /// The item identifier. + public Guid ItemId { get; set; } + } +} diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs new file mode 100644 index 0000000000..c371e8b946 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -0,0 +1,281 @@ +using System; +using System.Diagnostics; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.MediaEncoding.Attachments +{ + public class AttachmentExtractor : IAttachmentExtractor, IDisposable + { + private readonly ILogger _logger; + private readonly IApplicationPaths _appPaths; + private readonly IFileSystem _fileSystem; + private readonly IMediaEncoder _mediaEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + + private readonly ConcurrentDictionary _semaphoreLocks = + new ConcurrentDictionary(); + + private bool _disposed = false; + + public AttachmentExtractor( + ILogger logger, + IApplicationPaths appPaths, + IFileSystem fileSystem, + IMediaEncoder mediaEncoder, + IMediaSourceManager mediaSourceManager) + { + _logger = logger; + _appPaths = appPaths; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + _mediaSourceManager = mediaSourceManager; + } + + /// + public async Task<(MediaAttachment attachment, Stream stream)> GetAttachment(BaseItem item, string mediaSourceId, int attachmentStreamIndex, CancellationToken cancellationToken) + { + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + if (string.IsNullOrWhiteSpace(mediaSourceId)) + { + throw new ArgumentNullException(nameof(mediaSourceId)); + } + + var mediaSources = await _mediaSourceManager.GetPlayackMediaSources(item, null, true, false, cancellationToken).ConfigureAwait(false); + var mediaSource = mediaSources + .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)); + if (mediaSource == null) + { + throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found"); + } + + var mediaAttachment = mediaSource.MediaAttachments + .FirstOrDefault(i => i.Index == attachmentStreamIndex); + if (mediaAttachment == null) + { + throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {attachmentStreamIndex}"); + } + + var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken) + .ConfigureAwait(false); + + return (mediaAttachment, attachmentStream); + } + + private async Task GetAttachmentStream( + MediaSourceInfo mediaSource, + MediaAttachment mediaAttachment, + CancellationToken cancellationToken) + { + var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource.Protocol, mediaAttachment, cancellationToken).ConfigureAwait(false); + return File.OpenRead(attachmentPath); + } + + private async Task GetReadableFile( + string mediaPath, + string inputFile, + MediaProtocol protocol, + MediaAttachment mediaAttachment, + CancellationToken cancellationToken) + { + var outputPath = GetAttachmentCachePath(mediaPath, protocol, mediaAttachment.Index); + await ExtractAttachment(inputFile, protocol, mediaAttachment.Index, outputPath, cancellationToken) + .ConfigureAwait(false); + + return outputPath; + } + + private async Task ExtractAttachment( + string inputFile, + MediaProtocol protocol, + int attachmentStreamIndex, + string outputPath, + CancellationToken cancellationToken) + { + var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1)); + + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (!File.Exists(outputPath)) + { + await ExtractAttachmentInternal( + _mediaEncoder.GetInputArgument(new[] { inputFile }, protocol), + attachmentStreamIndex, + outputPath, + cancellationToken).ConfigureAwait(false); + } + } + finally + { + semaphore.Release(); + } + } + + private async Task ExtractAttachmentInternal( + string inputPath, + int attachmentStreamIndex, + string outputPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(inputPath)) + { + throw new ArgumentNullException(nameof(inputPath)); + } + + if (string.IsNullOrEmpty(outputPath)) + { + throw new ArgumentNullException(nameof(outputPath)); + } + + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + + var processArgs = string.Format( + CultureInfo.InvariantCulture, + "-dump_attachment:{1} {2} -i {0} -t 0 -f null null", + inputPath, + attachmentStreamIndex, + outputPath); + var startInfo = new ProcessStartInfo + { + Arguments = processArgs, + FileName = _mediaEncoder.EncoderPath, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }; + var process = new Process + { + StartInfo = startInfo + }; + + _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + + process.Start(); + + var processTcs = new TaskCompletionSource(); + process.EnableRaisingEvents = true; + process.Exited += (sender, args) => processTcs.TrySetResult(true); + var unregister = cancellationToken.Register(() => processTcs.TrySetResult(process.HasExited)); + var ranToCompletion = await processTcs.Task.ConfigureAwait(false); + unregister.Dispose(); + + if (!ranToCompletion) + { + try + { + _logger.LogWarning("Killing ffmpeg attachment extraction process"); + process.Kill(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error killing attachment extraction process"); + } + } + + var exitCode = ranToCompletion ? process.ExitCode : -1; + + process.Dispose(); + + var failed = false; + + if (exitCode != 0) + { + failed = true; + + _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, exitCode); + try + { + if (File.Exists(outputPath)) + { + _fileSystem.DeleteFile(outputPath); + } + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath); + } + } + else if (!File.Exists(outputPath)) + { + failed = true; + } + + if (failed) + { + var msg = $"ffmpeg attachment extraction failed for {inputPath} to {outputPath}"; + + _logger.LogError(msg); + + throw new InvalidOperationException(msg); + } + else + { + _logger.LogInformation("ffmpeg attachment extraction completed for {Path} to {Path}", inputPath, outputPath); + } + } + + private string GetAttachmentCachePath(string mediaPath, MediaProtocol protocol, int attachmentStreamIndex) + { + string filename; + if (protocol == MediaProtocol.File) + { + var date = _fileSystem.GetLastWriteTimeUtc(mediaPath); + filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D"); + } + else + { + filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D"); + } + + var prefix = filename.Substring(0, 1); + return Path.Combine(_appPaths.DataPath, "attachments", prefix, filename); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + + } + + _disposed = true; + } + } +} diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 6bcd6cd46a..e0f7b992ce 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -397,7 +397,8 @@ namespace MediaBrowser.MediaEncoding.Encoder try { result = await JsonSerializer.DeserializeAsync( - process.StandardOutput.BaseStream).ConfigureAwait(false); + process.StandardOutput.BaseStream, + cancellationToken: cancellationToken).ConfigureAwait(false); } catch { @@ -406,24 +407,24 @@ namespace MediaBrowser.MediaEncoding.Encoder throw; } - if (result == null || (result.streams == null && result.format == null)) + if (result == null || (result.Streams == null && result.Format == null)) { throw new Exception("ffprobe failed - streams and format are both null."); } - if (result.streams != null) + if (result.Streams != null) { // Normalize aspect ratio if invalid - foreach (var stream in result.streams) + foreach (var stream in result.Streams) { - if (string.Equals(stream.display_aspect_ratio, "0:1", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(stream.DisplayAspectRatio, "0:1", StringComparison.OrdinalIgnoreCase)) { - stream.display_aspect_ratio = string.Empty; + stream.DisplayAspectRatio = string.Empty; } - if (string.Equals(stream.sample_aspect_ratio, "0:1", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(stream.SampleAspectRatio, "0:1", StringComparison.OrdinalIgnoreCase)) { - stream.sample_aspect_ratio = string.Empty; + stream.SampleAspectRatio = string.Empty; } } } @@ -778,6 +779,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _runningProcesses.Add(process); } } + private void StopProcess(ProcessWrapper process, int waitTimeMs) { try @@ -786,18 +788,16 @@ namespace MediaBrowser.MediaEncoding.Encoder { return; } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in WaitForExit"); - } - try - { _logger.LogInformation("Killing ffmpeg process"); process.Process.Kill(); } + catch (InvalidOperationException) + { + // The process has already exited or + // there is no process associated with this Process object. + } catch (Exception ex) { _logger.LogError(ex, "Error killing process"); diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs index e4eabaf384..78dc7b6078 100644 --- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs +++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs @@ -16,24 +16,19 @@ namespace MediaBrowser.MediaEncoding.Probing throw new ArgumentNullException(nameof(result)); } - if (result.format != null && result.format.tags != null) + if (result.Format != null && result.Format.Tags != null) { - result.format.tags = ConvertDictionaryToCaseInSensitive(result.format.tags); + result.Format.Tags = ConvertDictionaryToCaseInsensitive(result.Format.Tags); } - if (result.streams != null) + if (result.Streams != null) { // Convert all dictionaries to case insensitive - foreach (var stream in result.streams) + foreach (var stream in result.Streams) { - if (stream.tags != null) + if (stream.Tags != null) { - stream.tags = ConvertDictionaryToCaseInSensitive(stream.tags); - } - - if (stream.disposition != null) - { - stream.disposition = ConvertDictionaryToCaseInSensitive(stream.disposition); + stream.Tags = ConvertDictionaryToCaseInsensitive(stream.Tags); } } } @@ -45,7 +40,7 @@ namespace MediaBrowser.MediaEncoding.Probing /// The tags. /// The key. /// System.String. - public static string GetDictionaryValue(Dictionary tags, string key) + public static string GetDictionaryValue(IReadOnlyDictionary tags, string key) { if (tags == null) { @@ -103,7 +98,7 @@ namespace MediaBrowser.MediaEncoding.Probing /// /// The dict. /// Dictionary{System.StringSystem.String}. - private static Dictionary ConvertDictionaryToCaseInSensitive(Dictionary dict) + private static Dictionary ConvertDictionaryToCaseInsensitive(IReadOnlyDictionary dict) { return new Dictionary(dict, StringComparer.OrdinalIgnoreCase); } diff --git a/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs b/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs index cc9d27608b..0e319c1a8b 100644 --- a/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs +++ b/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs @@ -1,9 +1,10 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; namespace MediaBrowser.MediaEncoding.Probing { /// - /// Class MediaInfoResult + /// Class MediaInfoResult. /// public class InternalMediaInfoResult { @@ -11,331 +12,21 @@ namespace MediaBrowser.MediaEncoding.Probing /// Gets or sets the streams. /// /// The streams. - public MediaStreamInfo[] streams { get; set; } + [JsonPropertyName("streams")] + public IReadOnlyList Streams { get; set; } /// /// Gets or sets the format. /// /// The format. - public MediaFormatInfo format { get; set; } + [JsonPropertyName("format")] + public MediaFormatInfo Format { get; set; } /// /// Gets or sets the chapters. /// /// The chapters. - public MediaChapter[] Chapters { get; set; } - } - - public class MediaChapter - { - public int id { get; set; } - public string time_base { get; set; } - public long start { get; set; } - public string start_time { get; set; } - public long end { get; set; } - public string end_time { get; set; } - public Dictionary tags { get; set; } - } - - /// - /// Represents a stream within the output - /// - public class MediaStreamInfo - { - /// - /// Gets or sets the index. - /// - /// The index. - public int index { get; set; } - - /// - /// Gets or sets the profile. - /// - /// The profile. - public string profile { get; set; } - - /// - /// Gets or sets the codec_name. - /// - /// The codec_name. - public string codec_name { get; set; } - - /// - /// Gets or sets the codec_long_name. - /// - /// The codec_long_name. - public string codec_long_name { get; set; } - - /// - /// Gets or sets the codec_type. - /// - /// The codec_type. - public string codec_type { get; set; } - - /// - /// Gets or sets the sample_rate. - /// - /// The sample_rate. - public string sample_rate { get; set; } - - /// - /// Gets or sets the channels. - /// - /// The channels. - public int channels { get; set; } - - /// - /// Gets or sets the channel_layout. - /// - /// The channel_layout. - public string channel_layout { get; set; } - - /// - /// Gets or sets the avg_frame_rate. - /// - /// The avg_frame_rate. - public string avg_frame_rate { get; set; } - - /// - /// Gets or sets the duration. - /// - /// The duration. - public string duration { get; set; } - - /// - /// Gets or sets the bit_rate. - /// - /// The bit_rate. - public string bit_rate { get; set; } - - /// - /// Gets or sets the width. - /// - /// The width. - public int width { get; set; } - - /// - /// Gets or sets the refs. - /// - /// The refs. - public int refs { get; set; } - - /// - /// Gets or sets the height. - /// - /// The height. - public int height { get; set; } - - /// - /// Gets or sets the display_aspect_ratio. - /// - /// The display_aspect_ratio. - public string display_aspect_ratio { get; set; } - - /// - /// Gets or sets the tags. - /// - /// The tags. - public Dictionary tags { get; set; } - - /// - /// Gets or sets the bits_per_sample. - /// - /// The bits_per_sample. - public int bits_per_sample { get; set; } - - /// - /// Gets or sets the bits_per_raw_sample. - /// - /// The bits_per_raw_sample. - public int bits_per_raw_sample { get; set; } - - /// - /// Gets or sets the r_frame_rate. - /// - /// The r_frame_rate. - public string r_frame_rate { get; set; } - - /// - /// Gets or sets the has_b_frames. - /// - /// The has_b_frames. - public int has_b_frames { get; set; } - - /// - /// Gets or sets the sample_aspect_ratio. - /// - /// The sample_aspect_ratio. - public string sample_aspect_ratio { get; set; } - - /// - /// Gets or sets the pix_fmt. - /// - /// The pix_fmt. - public string pix_fmt { get; set; } - - /// - /// Gets or sets the level. - /// - /// The level. - public int level { get; set; } - - /// - /// Gets or sets the time_base. - /// - /// The time_base. - public string time_base { get; set; } - - /// - /// Gets or sets the start_time. - /// - /// The start_time. - public string start_time { get; set; } - - /// - /// Gets or sets the codec_time_base. - /// - /// The codec_time_base. - public string codec_time_base { get; set; } - - /// - /// Gets or sets the codec_tag. - /// - /// The codec_tag. - public string codec_tag { get; set; } - - /// - /// Gets or sets the codec_tag_string. - /// - /// The codec_tag_string. - public string codec_tag_string { get; set; } - - /// - /// Gets or sets the sample_fmt. - /// - /// The sample_fmt. - public string sample_fmt { get; set; } - - /// - /// Gets or sets the dmix_mode. - /// - /// The dmix_mode. - public string dmix_mode { get; set; } - - /// - /// Gets or sets the start_pts. - /// - /// The start_pts. - public string start_pts { get; set; } - - /// - /// Gets or sets the is_avc. - /// - /// The is_avc. - public string is_avc { get; set; } - - /// - /// Gets or sets the nal_length_size. - /// - /// The nal_length_size. - public string nal_length_size { get; set; } - - /// - /// Gets or sets the ltrt_cmixlev. - /// - /// The ltrt_cmixlev. - public string ltrt_cmixlev { get; set; } - - /// - /// Gets or sets the ltrt_surmixlev. - /// - /// The ltrt_surmixlev. - public string ltrt_surmixlev { get; set; } - - /// - /// Gets or sets the loro_cmixlev. - /// - /// The loro_cmixlev. - public string loro_cmixlev { get; set; } - - /// - /// Gets or sets the loro_surmixlev. - /// - /// The loro_surmixlev. - public string loro_surmixlev { get; set; } - - public string field_order { get; set; } - - /// - /// Gets or sets the disposition. - /// - /// The disposition. - public Dictionary disposition { get; set; } - } - - /// - /// Class MediaFormat - /// - public class MediaFormatInfo - { - /// - /// Gets or sets the filename. - /// - /// The filename. - public string filename { get; set; } - - /// - /// Gets or sets the nb_streams. - /// - /// The nb_streams. - public int nb_streams { get; set; } - - /// - /// Gets or sets the format_name. - /// - /// The format_name. - public string format_name { get; set; } - - /// - /// Gets or sets the format_long_name. - /// - /// The format_long_name. - public string format_long_name { get; set; } - - /// - /// Gets or sets the start_time. - /// - /// The start_time. - public string start_time { get; set; } - - /// - /// Gets or sets the duration. - /// - /// The duration. - public string duration { get; set; } - - /// - /// Gets or sets the size. - /// - /// The size. - public string size { get; set; } - - /// - /// Gets or sets the bit_rate. - /// - /// The bit_rate. - public string bit_rate { get; set; } - - /// - /// Gets or sets the probe_score. - /// - /// The probe_score. - public int probe_score { get; set; } - - /// - /// Gets or sets the tags. - /// - /// The tags. - public Dictionary tags { get; set; } + [JsonPropertyName("chapters")] + public IReadOnlyList Chapters { get; set; } } } diff --git a/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs b/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs new file mode 100644 index 0000000000..6a45ccf495 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.MediaEncoding.Probing +{ + /// + /// Class MediaChapter. + /// + public class MediaChapter + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("time_base")] + public string TimeBase { get; set; } + + [JsonPropertyName("start")] + public long Start { get; set; } + + [JsonPropertyName("start_time")] + public string StartTime { get; set; } + + [JsonPropertyName("end")] + public long End { get; set; } + + [JsonPropertyName("end_time")] + public string EndTime { get; set; } + + [JsonPropertyName("tags")] + public IReadOnlyDictionary Tags { get; set; } + } +} diff --git a/MediaBrowser.MediaEncoding/Probing/MediaFormatInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaFormatInfo.cs new file mode 100644 index 0000000000..8af122ef9b --- /dev/null +++ b/MediaBrowser.MediaEncoding/Probing/MediaFormatInfo.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.MediaEncoding.Probing +{ + /// + /// Class MediaFormat. + /// + public class MediaFormatInfo + { + /// + /// Gets or sets the filename. + /// + /// The filename. + [JsonPropertyName("filename")] + public string FileName { get; set; } + + /// + /// Gets or sets the nb_streams. + /// + /// The nb_streams. + [JsonPropertyName("nb_streams")] + public int NbStreams { get; set; } + + /// + /// Gets or sets the format_name. + /// + /// The format_name. + [JsonPropertyName("format_name")] + public string FormatName { get; set; } + + /// + /// Gets or sets the format_long_name. + /// + /// The format_long_name. + [JsonPropertyName("format_long_name")] + public string FormatLongName { get; set; } + + /// + /// Gets or sets the start_time. + /// + /// The start_time. + [JsonPropertyName("start_time")] + public string StartTime { get; set; } + + /// + /// Gets or sets the duration. + /// + /// The duration. + [JsonPropertyName("duration")] + public string Duration { get; set; } + + /// + /// Gets or sets the size. + /// + /// The size. + [JsonPropertyName("size")] + public string Size { get; set; } + + /// + /// Gets or sets the bit_rate. + /// + /// The bit_rate. + [JsonPropertyName("bit_rate")] + public string BitRate { get; set; } + + /// + /// Gets or sets the probe_score. + /// + /// The probe_score. + [JsonPropertyName("probe_score")] + public int ProbeScore { get; set; } + + /// + /// Gets or sets the tags. + /// + /// The tags. + [JsonPropertyName("tags")] + public IReadOnlyDictionary Tags { get; set; } + } +} diff --git a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs new file mode 100644 index 0000000000..7fa7afa5b3 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs @@ -0,0 +1,282 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using MediaBrowser.Common.Json.Converters; + +namespace MediaBrowser.MediaEncoding.Probing +{ + /// + /// Represents a stream within the output. + /// + public class MediaStreamInfo + { + /// + /// Gets or sets the index. + /// + /// The index. + [JsonPropertyName("index")] + public int Index { get; set; } + + /// + /// Gets or sets the profile. + /// + /// The profile. + [JsonPropertyName("profile")] + public string Profile { get; set; } + + /// + /// Gets or sets the codec_name. + /// + /// The codec_name. + [JsonPropertyName("codec_name")] + public string CodecName { get; set; } + + /// + /// Gets or sets the codec_long_name. + /// + /// The codec_long_name. + [JsonPropertyName("codec_long_name")] + public string CodecLongName { get; set; } + + /// + /// Gets or sets the codec_type. + /// + /// The codec_type. + [JsonPropertyName("codec_type")] + public string CodecType { get; set; } + + /// + /// Gets or sets the sample_rate. + /// + /// The sample_rate. + [JsonPropertyName("sample_rate")] + public string SampleRate { get; set; } + + /// + /// Gets or sets the channels. + /// + /// The channels. + [JsonPropertyName("channels")] + public int Channels { get; set; } + + /// + /// Gets or sets the channel_layout. + /// + /// The channel_layout. + [JsonPropertyName("channel_layout")] + public string ChannelLayout { get; set; } + + /// + /// Gets or sets the avg_frame_rate. + /// + /// The avg_frame_rate. + [JsonPropertyName("avg_frame_rate")] + public string AverageFrameRate { get; set; } + + /// + /// Gets or sets the duration. + /// + /// The duration. + [JsonPropertyName("duration")] + public string Duration { get; set; } + + /// + /// Gets or sets the bit_rate. + /// + /// The bit_rate. + [JsonPropertyName("bit_rate")] + public string BitRate { get; set; } + + /// + /// Gets or sets the width. + /// + /// The width. + [JsonPropertyName("width")] + public int Width { get; set; } + + /// + /// Gets or sets the refs. + /// + /// The refs. + [JsonPropertyName("refs")] + public int Refs { get; set; } + + /// + /// Gets or sets the height. + /// + /// The height. + [JsonPropertyName("height")] + public int Height { get; set; } + + /// + /// Gets or sets the display_aspect_ratio. + /// + /// The display_aspect_ratio. + [JsonPropertyName("display_aspect_ratio")] + public string DisplayAspectRatio { get; set; } + + /// + /// Gets or sets the tags. + /// + /// The tags. + [JsonPropertyName("tags")] + public IReadOnlyDictionary Tags { get; set; } + + /// + /// Gets or sets the bits_per_sample. + /// + /// The bits_per_sample. + [JsonPropertyName("bits_per_sample")] + public int BitsPerSample { get; set; } + + /// + /// Gets or sets the bits_per_raw_sample. + /// + /// The bits_per_raw_sample. + [JsonPropertyName("bits_per_raw_sample")] + [JsonConverter(typeof(JsonInt32Converter))] + public int BitsPerRawSample { get; set; } + + /// + /// Gets or sets the r_frame_rate. + /// + /// The r_frame_rate. + [JsonPropertyName("r_frame_rate")] + public string RFrameRate { get; set; } + + /// + /// Gets or sets the has_b_frames. + /// + /// The has_b_frames. + [JsonPropertyName("has_b_frames")] + public int HasBFrames { get; set; } + + /// + /// Gets or sets the sample_aspect_ratio. + /// + /// The sample_aspect_ratio. + [JsonPropertyName("sample_aspect_ratio")] + public string SampleAspectRatio { get; set; } + + /// + /// Gets or sets the pix_fmt. + /// + /// The pix_fmt. + [JsonPropertyName("pix_fmt")] + public string PixelFormat { get; set; } + + /// + /// Gets or sets the level. + /// + /// The level. + [JsonPropertyName("level")] + public int Level { get; set; } + + /// + /// Gets or sets the time_base. + /// + /// The time_base. + [JsonPropertyName("time_base")] + public string TimeBase { get; set; } + + /// + /// Gets or sets the start_time. + /// + /// The start_time. + [JsonPropertyName("start_time")] + public string StartTime { get; set; } + + /// + /// Gets or sets the codec_time_base. + /// + /// The codec_time_base. + [JsonPropertyName("codec_time_base")] + public string CodecTimeBase { get; set; } + + /// + /// Gets or sets the codec_tag. + /// + /// The codec_tag. + [JsonPropertyName("codec_tag")] + public string CodecTag { get; set; } + + /// + /// Gets or sets the codec_tag_string. + /// + /// The codec_tag_string. + [JsonPropertyName("codec_tag_string")] + public string CodecTagString { get; set; } + + /// + /// Gets or sets the sample_fmt. + /// + /// The sample_fmt. + [JsonPropertyName("sample_fmt")] + public string SampleFmt { get; set; } + + /// + /// Gets or sets the dmix_mode. + /// + /// The dmix_mode. + [JsonPropertyName("dmix_mode")] + public string DmixMode { get; set; } + + /// + /// Gets or sets the start_pts. + /// + /// The start_pts. + [JsonPropertyName("start_pts")] + public int StartPts { get; set; } + + /// + /// Gets or sets the is_avc. + /// + /// The is_avc. + [JsonPropertyName("is_avc")] + public string IsAvc { get; set; } + + /// + /// Gets or sets the nal_length_size. + /// + /// The nal_length_size. + [JsonPropertyName("nal_length_size")] + public string NalLengthSize { get; set; } + + /// + /// Gets or sets the ltrt_cmixlev. + /// + /// The ltrt_cmixlev. + [JsonPropertyName("ltrt_cmixlev")] + public string LtrtCmixlev { get; set; } + + /// + /// Gets or sets the ltrt_surmixlev. + /// + /// The ltrt_surmixlev. + [JsonPropertyName("ltrt_surmixlev")] + public string LtrtSurmixlev { get; set; } + + /// + /// Gets or sets the loro_cmixlev. + /// + /// The loro_cmixlev. + [JsonPropertyName("loro_cmixlev")] + public string LoroCmixlev { get; set; } + + /// + /// Gets or sets the loro_surmixlev. + /// + /// The loro_surmixlev. + [JsonPropertyName("loro_surmixlev")] + public string LoroSurmixlev { get; set; } + + [JsonPropertyName("field_order")] + public string FieldOrder { get; set; } + + /// + /// Gets or sets the disposition. + /// + /// The disposition. + [JsonPropertyName("disposition")] + public IReadOnlyDictionary Disposition { get; set; } + } +} diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 54d02fc9f7..bd89c6cae9 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -8,7 +8,6 @@ using System.Xml; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; @@ -41,21 +40,25 @@ namespace MediaBrowser.MediaEncoding.Probing FFProbeHelpers.NormalizeFFProbeResult(data); SetSize(data, info); - var internalStreams = data.streams ?? new MediaStreamInfo[] { }; + var internalStreams = data.Streams ?? new MediaStreamInfo[] { }; - info.MediaStreams = internalStreams.Select(s => GetMediaStream(isAudio, s, data.format)) + info.MediaStreams = internalStreams.Select(s => GetMediaStream(isAudio, s, data.Format)) .Where(i => i != null) // Drop subtitle streams if we don't know the codec because it will just cause failures if we don't know how to handle them .Where(i => i.Type != MediaStreamType.Subtitle || !string.IsNullOrWhiteSpace(i.Codec)) .ToList(); - if (data.format != null) - { - info.Container = NormalizeFormat(data.format.format_name); + info.MediaAttachments = internalStreams.Select(s => GetMediaAttachment(s)) + .Where(i => i != null) + .ToList(); - if (!string.IsNullOrEmpty(data.format.bit_rate)) + if (data.Format != null) + { + info.Container = NormalizeFormat(data.Format.FormatName); + + if (!string.IsNullOrEmpty(data.Format.BitRate)) { - if (int.TryParse(data.format.bit_rate, NumberStyles.Any, _usCulture, out var value)) + if (int.TryParse(data.Format.BitRate, NumberStyles.Any, _usCulture, out var value)) { info.Bitrate = value; } @@ -65,22 +68,22 @@ namespace MediaBrowser.MediaEncoding.Probing var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); var tagStreamType = isAudio ? "audio" : "video"; - if (data.streams != null) + if (data.Streams != null) { - var tagStream = data.streams.FirstOrDefault(i => string.Equals(i.codec_type, tagStreamType, StringComparison.OrdinalIgnoreCase)); + var tagStream = data.Streams.FirstOrDefault(i => string.Equals(i.CodecType, tagStreamType, StringComparison.OrdinalIgnoreCase)); - if (tagStream != null && tagStream.tags != null) + if (tagStream != null && tagStream.Tags != null) { - foreach (var pair in tagStream.tags) + foreach (var pair in tagStream.Tags) { tags[pair.Key] = pair.Value; } } } - if (data.format != null && data.format.tags != null) + if (data.Format != null && data.Format.Tags != null) { - foreach (var pair in data.format.tags) + foreach (var pair in data.Format.Tags) { tags[pair.Key] = pair.Value; } @@ -153,9 +156,9 @@ namespace MediaBrowser.MediaEncoding.Probing FetchFromItunesInfo(itunesXml, info); } - if (data.format != null && !string.IsNullOrEmpty(data.format.duration)) + if (data.Format != null && !string.IsNullOrEmpty(data.Format.Duration)) { - info.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(data.format.duration, _usCulture)).Ticks; + info.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(data.Format.Duration, _usCulture)).Ticks; } FetchWtvInfo(info, data); @@ -513,6 +516,39 @@ namespace MediaBrowser.MediaEncoding.Probing return codec; } + /// + /// Converts ffprobe stream info to our MediaAttachment class + /// + /// The stream info. + /// MediaAttachments. + private MediaAttachment GetMediaAttachment(MediaStreamInfo streamInfo) + { + if (!string.Equals(streamInfo.CodecType, "attachment", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var attachment = new MediaAttachment + { + Codec = streamInfo.CodecName, + Index = streamInfo.Index + }; + + if (!string.IsNullOrWhiteSpace(streamInfo.CodecTagString)) + { + attachment.CodecTag = streamInfo.CodecTagString; + } + + if (streamInfo.Tags != null) + { + attachment.FileName = GetDictionaryValue(streamInfo.Tags, "filename"); + attachment.MimeType = GetDictionaryValue(streamInfo.Tags, "mimetype"); + attachment.Comment = GetDictionaryValue(streamInfo.Tags, "comment"); + } + + return attachment; + } + /// /// Converts ffprobe stream info to our MediaStream class /// @@ -523,7 +559,7 @@ namespace MediaBrowser.MediaEncoding.Probing private MediaStream GetMediaStream(bool isAudio, MediaStreamInfo streamInfo, MediaFormatInfo formatInfo) { // These are mp4 chapters - if (string.Equals(streamInfo.codec_name, "mov_text", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(streamInfo.CodecName, "mov_text", StringComparison.OrdinalIgnoreCase)) { // Edit: but these are also sometimes subtitles? //return null; @@ -531,71 +567,71 @@ namespace MediaBrowser.MediaEncoding.Probing var stream = new MediaStream { - Codec = streamInfo.codec_name, - Profile = streamInfo.profile, - Level = streamInfo.level, - Index = streamInfo.index, - PixelFormat = streamInfo.pix_fmt, - NalLengthSize = streamInfo.nal_length_size, - TimeBase = streamInfo.time_base, - CodecTimeBase = streamInfo.codec_time_base + Codec = streamInfo.CodecName, + Profile = streamInfo.Profile, + Level = streamInfo.Level, + Index = streamInfo.Index, + PixelFormat = streamInfo.PixelFormat, + NalLengthSize = streamInfo.NalLengthSize, + TimeBase = streamInfo.TimeBase, + CodecTimeBase = streamInfo.CodecTimeBase }; - if (string.Equals(streamInfo.is_avc, "true", StringComparison.OrdinalIgnoreCase) || - string.Equals(streamInfo.is_avc, "1", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(streamInfo.IsAvc, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(streamInfo.IsAvc, "1", StringComparison.OrdinalIgnoreCase)) { stream.IsAVC = true; } - else if (string.Equals(streamInfo.is_avc, "false", StringComparison.OrdinalIgnoreCase) || - string.Equals(streamInfo.is_avc, "0", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(streamInfo.IsAvc, "false", StringComparison.OrdinalIgnoreCase) || + string.Equals(streamInfo.IsAvc, "0", StringComparison.OrdinalIgnoreCase)) { stream.IsAVC = false; } - if (!string.IsNullOrWhiteSpace(streamInfo.field_order) && !string.Equals(streamInfo.field_order, "progressive", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrWhiteSpace(streamInfo.FieldOrder) && !string.Equals(streamInfo.FieldOrder, "progressive", StringComparison.OrdinalIgnoreCase)) { stream.IsInterlaced = true; } // Filter out junk - if (!string.IsNullOrWhiteSpace(streamInfo.codec_tag_string) && streamInfo.codec_tag_string.IndexOf("[0]", StringComparison.OrdinalIgnoreCase) == -1) + if (!string.IsNullOrWhiteSpace(streamInfo.CodecTagString) && streamInfo.CodecTagString.IndexOf("[0]", StringComparison.OrdinalIgnoreCase) == -1) { - stream.CodecTag = streamInfo.codec_tag_string; + stream.CodecTag = streamInfo.CodecTagString; } - if (streamInfo.tags != null) + if (streamInfo.Tags != null) { - stream.Language = GetDictionaryValue(streamInfo.tags, "language"); - stream.Comment = GetDictionaryValue(streamInfo.tags, "comment"); - stream.Title = GetDictionaryValue(streamInfo.tags, "title"); + stream.Language = GetDictionaryValue(streamInfo.Tags, "language"); + stream.Comment = GetDictionaryValue(streamInfo.Tags, "comment"); + stream.Title = GetDictionaryValue(streamInfo.Tags, "title"); } - if (string.Equals(streamInfo.codec_type, "audio", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase)) { stream.Type = MediaStreamType.Audio; - stream.Channels = streamInfo.channels; + stream.Channels = streamInfo.Channels; - if (!string.IsNullOrEmpty(streamInfo.sample_rate)) + if (!string.IsNullOrEmpty(streamInfo.SampleRate)) { - if (int.TryParse(streamInfo.sample_rate, NumberStyles.Any, _usCulture, out var value)) + if (int.TryParse(streamInfo.SampleRate, NumberStyles.Any, _usCulture, out var value)) { stream.SampleRate = value; } } - stream.ChannelLayout = ParseChannelLayout(streamInfo.channel_layout); + stream.ChannelLayout = ParseChannelLayout(streamInfo.ChannelLayout); - if (streamInfo.bits_per_sample > 0) + if (streamInfo.BitsPerSample > 0) { - stream.BitDepth = streamInfo.bits_per_sample; + stream.BitDepth = streamInfo.BitsPerSample; } - else if (streamInfo.bits_per_raw_sample > 0) + else if (streamInfo.BitsPerRawSample > 0) { - stream.BitDepth = streamInfo.bits_per_raw_sample; + stream.BitDepth = streamInfo.BitsPerRawSample; } } - else if (string.Equals(streamInfo.codec_type, "subtitle", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(streamInfo.CodecType, "subtitle", StringComparison.OrdinalIgnoreCase)) { stream.Type = MediaStreamType.Subtitle; stream.Codec = NormalizeSubtitleCodec(stream.Codec); @@ -603,14 +639,14 @@ namespace MediaBrowser.MediaEncoding.Probing stream.localizedDefault = _localization.GetLocalizedString("Default"); stream.localizedForced = _localization.GetLocalizedString("Forced"); } - else if (string.Equals(streamInfo.codec_type, "video", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase)) { stream.Type = isAudio || string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase) || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase) ? MediaStreamType.EmbeddedImage : MediaStreamType.Video; - stream.AverageFrameRate = GetFrameRate(streamInfo.avg_frame_rate); - stream.RealFrameRate = GetFrameRate(streamInfo.r_frame_rate); + stream.AverageFrameRate = GetFrameRate(streamInfo.AverageFrameRate); + stream.RealFrameRate = GetFrameRate(streamInfo.RFrameRate); if (isAudio || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)) @@ -635,17 +671,17 @@ namespace MediaBrowser.MediaEncoding.Probing stream.Type = MediaStreamType.Video; } - stream.Width = streamInfo.width; - stream.Height = streamInfo.height; + stream.Width = streamInfo.Width; + stream.Height = streamInfo.Height; stream.AspectRatio = GetAspectRatio(streamInfo); - if (streamInfo.bits_per_sample > 0) + if (streamInfo.BitsPerSample > 0) { - stream.BitDepth = streamInfo.bits_per_sample; + stream.BitDepth = streamInfo.BitsPerSample; } - else if (streamInfo.bits_per_raw_sample > 0) + else if (streamInfo.BitsPerRawSample > 0) { - stream.BitDepth = streamInfo.bits_per_raw_sample; + stream.BitDepth = streamInfo.BitsPerRawSample; } //stream.IsAnamorphic = string.Equals(streamInfo.sample_aspect_ratio, "0:1", StringComparison.OrdinalIgnoreCase) || @@ -653,11 +689,11 @@ namespace MediaBrowser.MediaEncoding.Probing // string.Equals(stream.AspectRatio, "2.40:1", StringComparison.OrdinalIgnoreCase); // http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe - stream.IsAnamorphic = string.Equals(streamInfo.sample_aspect_ratio, "0:1", StringComparison.OrdinalIgnoreCase); + stream.IsAnamorphic = string.Equals(streamInfo.SampleAspectRatio, "0:1", StringComparison.OrdinalIgnoreCase); - if (streamInfo.refs > 0) + if (streamInfo.Refs > 0) { - stream.RefFrames = streamInfo.refs; + stream.RefFrames = streamInfo.Refs; } } else @@ -668,18 +704,18 @@ namespace MediaBrowser.MediaEncoding.Probing // Get stream bitrate var bitrate = 0; - if (!string.IsNullOrEmpty(streamInfo.bit_rate)) + if (!string.IsNullOrEmpty(streamInfo.BitRate)) { - if (int.TryParse(streamInfo.bit_rate, NumberStyles.Any, _usCulture, out var value)) + if (int.TryParse(streamInfo.BitRate, NumberStyles.Any, _usCulture, out var value)) { bitrate = value; } } - if (bitrate == 0 && formatInfo != null && !string.IsNullOrEmpty(formatInfo.bit_rate) && stream.Type == MediaStreamType.Video) + if (bitrate == 0 && formatInfo != null && !string.IsNullOrEmpty(formatInfo.BitRate) && stream.Type == MediaStreamType.Video) { // If the stream info doesn't have a bitrate get the value from the media format info - if (int.TryParse(formatInfo.bit_rate, NumberStyles.Any, _usCulture, out var value)) + if (int.TryParse(formatInfo.BitRate, NumberStyles.Any, _usCulture, out var value)) { bitrate = value; } @@ -690,14 +726,18 @@ namespace MediaBrowser.MediaEncoding.Probing stream.BitRate = bitrate; } - if (streamInfo.disposition != null) + var disposition = streamInfo.Disposition; + if (disposition != null) { - var isDefault = GetDictionaryValue(streamInfo.disposition, "default"); - var isForced = GetDictionaryValue(streamInfo.disposition, "forced"); + if (disposition.GetValueOrDefault("default") == 1) + { + stream.IsDefault = true; + } - stream.IsDefault = string.Equals(isDefault, "1", StringComparison.OrdinalIgnoreCase); - - stream.IsForced = string.Equals(isForced, "1", StringComparison.OrdinalIgnoreCase); + if (disposition.GetValueOrDefault("forced") == 1) + { + stream.IsForced = true; + } } NormalizeStreamTitle(stream); @@ -724,7 +764,7 @@ namespace MediaBrowser.MediaEncoding.Probing /// The tags. /// The key. /// System.String. - private string GetDictionaryValue(Dictionary tags, string key) + private string GetDictionaryValue(IReadOnlyDictionary tags, string key) { if (tags == null) { @@ -747,7 +787,7 @@ namespace MediaBrowser.MediaEncoding.Probing private string GetAspectRatio(MediaStreamInfo info) { - var original = info.display_aspect_ratio; + var original = info.DisplayAspectRatio; var parts = (original ?? string.Empty).Split(':'); if (!(parts.Length == 2 && @@ -756,8 +796,8 @@ namespace MediaBrowser.MediaEncoding.Probing width > 0 && height > 0)) { - width = info.width; - height = info.height; + width = info.Width; + height = info.Height; } if (width > 0 && height > 0) @@ -850,20 +890,20 @@ namespace MediaBrowser.MediaEncoding.Probing private void SetAudioRuntimeTicks(InternalMediaInfoResult result, MediaInfo data) { - if (result.streams != null) + if (result.Streams != null) { // Get the first info stream - var stream = result.streams.FirstOrDefault(s => string.Equals(s.codec_type, "audio", StringComparison.OrdinalIgnoreCase)); + var stream = result.Streams.FirstOrDefault(s => string.Equals(s.CodecType, "audio", StringComparison.OrdinalIgnoreCase)); if (stream != null) { // Get duration from stream properties - var duration = stream.duration; + var duration = stream.Duration; // If it's not there go into format properties if (string.IsNullOrEmpty(duration)) { - duration = result.format.duration; + duration = result.Format.Duration; } // If we got something, parse it @@ -877,11 +917,11 @@ namespace MediaBrowser.MediaEncoding.Probing private void SetSize(InternalMediaInfoResult data, MediaInfo info) { - if (data.format != null) + if (data.Format != null) { - if (!string.IsNullOrEmpty(data.format.size)) + if (!string.IsNullOrEmpty(data.Format.Size)) { - info.Size = long.Parse(data.format.size, _usCulture); + info.Size = long.Parse(data.Format.Size, _usCulture); } else { @@ -1194,16 +1234,16 @@ namespace MediaBrowser.MediaEncoding.Probing { var info = new ChapterInfo(); - if (chapter.tags != null) + if (chapter.Tags != null) { - if (chapter.tags.TryGetValue("title", out string name)) + if (chapter.Tags.TryGetValue("title", out string name)) { info.Name = name; } } // Limit accuracy to milliseconds to match xml saving - var secondsString = chapter.start_time; + var secondsString = chapter.StartTime; if (double.TryParse(secondsString, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) { @@ -1218,12 +1258,12 @@ namespace MediaBrowser.MediaEncoding.Probing private void FetchWtvInfo(MediaInfo video, InternalMediaInfoResult data) { - if (data.format == null || data.format.tags == null) + if (data.Format == null || data.Format.Tags == null) { return; } - var genres = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/Genre"); + var genres = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/Genre"); if (!string.IsNullOrWhiteSpace(genres)) { @@ -1239,14 +1279,14 @@ namespace MediaBrowser.MediaEncoding.Probing } } - var officialRating = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/ParentalRating"); + var officialRating = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/ParentalRating"); if (!string.IsNullOrWhiteSpace(officialRating)) { video.OfficialRating = officialRating; } - var people = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/MediaCredits"); + var people = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/MediaCredits"); if (!string.IsNullOrEmpty(people)) { @@ -1256,7 +1296,7 @@ namespace MediaBrowser.MediaEncoding.Probing .ToArray(); } - var year = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/OriginalReleaseTime"); + var year = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/OriginalReleaseTime"); if (!string.IsNullOrWhiteSpace(year)) { if (int.TryParse(year, NumberStyles.Integer, _usCulture, out var val)) @@ -1265,7 +1305,7 @@ namespace MediaBrowser.MediaEncoding.Probing } } - var premiereDateString = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/MediaOriginalBroadcastDateTime"); + var premiereDateString = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/MediaOriginalBroadcastDateTime"); if (!string.IsNullOrWhiteSpace(premiereDateString)) { // Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ @@ -1276,9 +1316,9 @@ namespace MediaBrowser.MediaEncoding.Probing } } - var description = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/SubTitleDescription"); + var description = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/SubTitleDescription"); - var subTitle = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/SubTitle"); + var subTitle = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/SubTitle"); // For below code, credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ @@ -1334,24 +1374,25 @@ namespace MediaBrowser.MediaEncoding.Probing { video.Timestamp = GetMpegTimestamp(video.Path); - _logger.LogDebug("Video has {timestamp} timestamp", video.Timestamp); + _logger.LogDebug("Video has {Timestamp} timestamp", video.Timestamp); } catch (Exception ex) { - _logger.LogError(ex, "Error extracting timestamp info from {path}", video.Path); + _logger.LogError(ex, "Error extracting timestamp info from {Path}", video.Path); video.Timestamp = null; } } } } + // REVIEW: find out why the byte array needs to be 197 bytes long and comment the reason private TransportStreamTimestamp GetMpegTimestamp(string path) { - var packetBuffer = new byte['Å']; + var packetBuffer = new byte[197]; - using (var fs = _fileSystem.GetFileStream(path, FileOpenMode.Open, FileAccessMode.Read, FileShareMode.Read)) + using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) { - fs.Read(packetBuffer, 0, packetBuffer.Length); + fs.Read(packetBuffer); } if (packetBuffer[0] == 71) @@ -1359,7 +1400,7 @@ namespace MediaBrowser.MediaEncoding.Probing return TransportStreamTimestamp.None; } - if ((packetBuffer[4] == 71) && (packetBuffer['Ä'] == 71)) + if ((packetBuffer[4] == 71) && (packetBuffer[196] == 71)) { if ((packetBuffer[0] == 0) && (packetBuffer[1] == 0) && (packetBuffer[2] == 0) && (packetBuffer[3] == 0)) { diff --git a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs index 241ebc6df5..1b452b0cec 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs @@ -16,6 +16,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles using (var writer = new Utf8JsonWriter(stream)) { var trackevents = info.TrackEvents; + writer.WriteStartObject(); writer.WriteStartArray("TrackEvents"); for (int i = 0; i < trackevents.Count; i++) @@ -33,7 +34,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles writer.WriteEndObject(); } + writer.WriteEndArray(); writer.WriteEndObject(); + + writer.Flush(); } } } diff --git a/MediaBrowser.MediaEncoding/packages.config b/MediaBrowser.MediaEncoding/packages.config deleted file mode 100644 index bbeaf5f002..0000000000 --- a/MediaBrowser.MediaEncoding/packages.config +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs index 5bdc4809a9..5cb0565666 100644 --- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs +++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs @@ -57,6 +57,8 @@ namespace MediaBrowser.Model.Dto public List MediaStreams { get; set; } + public IReadOnlyList MediaAttachments { get; set; } + public string[] Formats { get; set; } public int? Bitrate { get; set; } diff --git a/MediaBrowser.Model/Entities/MediaAttachment.cs b/MediaBrowser.Model/Entities/MediaAttachment.cs new file mode 100644 index 0000000000..8f8c3efd20 --- /dev/null +++ b/MediaBrowser.Model/Entities/MediaAttachment.cs @@ -0,0 +1,50 @@ +namespace MediaBrowser.Model.Entities +{ + /// + /// Class MediaAttachment + /// + public class MediaAttachment + { + /// + /// Gets or sets the codec. + /// + /// The codec. + public string Codec { get; set; } + + /// + /// Gets or sets the codec tag. + /// + /// The codec tag. + public string CodecTag { get; set; } + + /// + /// Gets or sets the comment. + /// + /// The comment. + public string Comment { get; set; } + + /// + /// Gets or sets the index. + /// + /// The index. + public int Index { get; set; } + + /// + /// Gets or sets the filename. + /// + /// The filename. + public string FileName { get; set; } + + /// + /// Gets or sets the MIME type. + /// + /// The MIME type. + public string MimeType { get; set; } + + /// + /// Gets or sets the delivery URL. + /// + /// The delivery URL. + public string DeliveryUrl { get; set; } + } +} diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index d2abd2a639..2b178d4d4e 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -158,11 +158,13 @@ namespace MediaBrowser.Providers.MediaInfo MetadataRefreshOptions options) { List mediaStreams; + IReadOnlyList mediaAttachments; List chapters; if (mediaInfo != null) { mediaStreams = mediaInfo.MediaStreams; + mediaAttachments = mediaInfo.MediaAttachments; video.TotalBitrate = mediaInfo.Bitrate; //video.FormatName = (mediaInfo.Container ?? string.Empty) @@ -198,6 +200,7 @@ namespace MediaBrowser.Providers.MediaInfo else { mediaStreams = new List(); + mediaAttachments = Array.Empty(); chapters = new List(); } @@ -210,19 +213,20 @@ namespace MediaBrowser.Providers.MediaInfo FetchEmbeddedInfo(video, mediaInfo, options, libraryOptions); FetchPeople(video, mediaInfo, options); video.Timestamp = mediaInfo.Timestamp; - video.Video3DFormat = video.Video3DFormat ?? mediaInfo.Video3DFormat; + video.Video3DFormat ??= mediaInfo.Video3DFormat; } var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); - video.Height = videoStream == null ? 0 : videoStream.Height ?? 0; - video.Width = videoStream == null ? 0 : videoStream.Width ?? 0; + video.Height = videoStream?.Height ?? 0; + video.Width = videoStream?.Width ?? 0; video.DefaultVideoStreamIndex = videoStream == null ? (int?)null : videoStream.Index; video.HasSubtitles = mediaStreams.Any(i => i.Type == MediaStreamType.Subtitle); _itemRepo.SaveMediaStreams(video.Id, mediaStreams, cancellationToken); + _itemRepo.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken); if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || options.MetadataRefreshMode == MetadataRefreshMode.Default)