Parcourir la source

Merge branch 'master' into EraYaN-add-management-interface

Claus Vium il y a 3 ans
Parent
commit
bc69aa251b
100 fichiers modifiés avec 2222 ajouts et 1041 suppressions
  1. 4 1
      .ci/azure-pipelines-abi.yml
  2. 0 78
      .ci/azure-pipelines-api-client.yml
  3. 1 1
      .ci/azure-pipelines-main.yml
  4. 30 16
      .ci/azure-pipelines-package.yml
  5. 4 4
      .ci/azure-pipelines-test.yml
  6. 1 4
      .ci/azure-pipelines.yml
  7. 0 30
      .drone.yml
  8. 1 0
      .github/CODEOWNERS
  9. 8 1
      .github/ISSUE_TEMPLATE/bug_report.md
  10. 7 1
      .github/dependabot.yml
  11. 5 1
      .github/stale.yml
  12. 76 0
      .github/workflows/automation.yml
  13. 36 0
      .github/workflows/codeql-analysis.yml
  14. 119 0
      .github/workflows/commands.yml
  15. 1 0
      .gitignore
  16. 2 2
      .vscode/launch.json
  17. 11 0
      CONTRIBUTORS.md
  18. 14 0
      Directory.Build.props
  19. 39 16
      Dockerfile
  20. 19 19
      Dockerfile.arm
  21. 19 18
      Dockerfile.arm64
  22. 3 2
      DvdLib/DvdLib.csproj
  23. 1 1
      DvdLib/Ifo/Dvd.cs
  24. 15 5
      Emby.Dlna/Common/Argument.cs
  25. 22 10
      Emby.Dlna/Common/DeviceIcon.cs
  26. 24 9
      Emby.Dlna/Common/DeviceService.cs
  27. 14 7
      Emby.Dlna/Common/ServiceAction.cs
  28. 19 12
      Emby.Dlna/Common/StateVariable.cs
  29. 65 6
      Emby.Dlna/Configuration/DlnaOptions.cs
  30. 0 1
      Emby.Dlna/ConfigurationExtension.cs
  31. 11 1
      Emby.Dlna/ConnectionManager/ConnectionManagerService.cs
  32. 79 66
      Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs
  33. 14 1
      Emby.Dlna/ConnectionManager/ControlHandler.cs
  34. 32 5
      Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs
  35. 36 5
      Emby.Dlna/ContentDirectory/ContentDirectoryService.cs
  36. 120 109
      Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs
  37. 439 131
      Emby.Dlna/ContentDirectory/ControlHandler.cs
  38. 13 0
      Emby.Dlna/ContentDirectory/ServerItem.cs
  39. 44 7
      Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs
  40. 3 1
      Emby.Dlna/ContentDirectory/StubType.cs
  41. 2 0
      Emby.Dlna/ControlRequest.cs
  42. 3 1
      Emby.Dlna/ControlResponse.cs
  43. 28 12
      Emby.Dlna/Didl/DidlBuilder.cs
  44. 1 1
      Emby.Dlna/Didl/Filter.cs
  45. 1 1
      Emby.Dlna/Didl/StringWriterWithEncoding.cs
  46. 0 1
      Emby.Dlna/DlnaConfigurationFactory.cs
  47. 94 111
      Emby.Dlna/DlnaManager.cs
  48. 3 10
      Emby.Dlna/Emby.Dlna.csproj
  49. 3 1
      Emby.Dlna/EventSubscriptionResponse.cs
  50. 9 18
      Emby.Dlna/Eventing/DlnaEventManager.cs
  51. 2 0
      Emby.Dlna/Eventing/EventSubscription.cs
  52. 70 24
      Emby.Dlna/Main/DlnaEntryPoint.cs
  53. 1 1
      Emby.Dlna/MediaReceiverRegistrar/ControlHandler.cs
  54. 0 1
      Emby.Dlna/MediaReceiverRegistrar/MediaReceiverRegistrarXmlBuilder.cs
  55. 0 1
      Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs
  56. 127 42
      Emby.Dlna/PlayTo/Device.cs
  57. 2 0
      Emby.Dlna/PlayTo/DeviceInfo.cs
  58. 7 1
      Emby.Dlna/PlayTo/MediaChangedEventArgs.cs
  59. 79 39
      Emby.Dlna/PlayTo/PlayToController.cs
  60. 31 27
      Emby.Dlna/PlayTo/PlayToManager.cs
  61. 5 0
      Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs
  62. 5 0
      Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs
  63. 5 0
      Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs
  64. 2 0
      Emby.Dlna/PlayTo/PlaylistItem.cs
  65. 4 2
      Emby.Dlna/PlayTo/PlaylistItemFactory.cs
  66. 20 13
      Emby.Dlna/PlayTo/SsdpHttpClient.cs
  67. 17 19
      Emby.Dlna/PlayTo/TransportCommands.cs
  68. 0 1
      Emby.Dlna/PlayTo/TransportState.cs
  69. 2 0
      Emby.Dlna/PlayTo/uBaseObject.cs
  70. 3 0
      Emby.Dlna/Profiles/DefaultProfile.cs
  71. 2 2
      Emby.Dlna/Profiles/SonyBravia2010Profile.cs
  72. 2 2
      Emby.Dlna/Profiles/SonyBravia2011Profile.cs
  73. 2 2
      Emby.Dlna/Profiles/SonyBravia2012Profile.cs
  74. 2 2
      Emby.Dlna/Profiles/SonyBravia2013Profile.cs
  75. 2 2
      Emby.Dlna/Profiles/SonyBravia2014Profile.cs
  76. 2 2
      Emby.Dlna/Profiles/SonyPs3Profile.cs
  77. 2 2
      Emby.Dlna/Profiles/SonyPs4Profile.cs
  78. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2010).xml
  79. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2011).xml
  80. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2012).xml
  81. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2013).xml
  82. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2014).xml
  83. 2 2
      Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml
  84. 2 2
      Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml
  85. 2 0
      Emby.Dlna/Properties/AssemblyInfo.cs
  86. 3 18
      Emby.Dlna/Server/DescriptionXmlBuilder.cs
  87. 7 10
      Emby.Dlna/Service/BaseControlHandler.cs
  88. 1 5
      Emby.Dlna/Service/ControlErrorHandler.cs
  89. 4 2
      Emby.Dlna/Ssdp/DeviceDiscovery.cs
  90. 3 3
      Emby.Dlna/Ssdp/SsdpExtensions.cs
  91. 2 8
      Emby.Drawing/Emby.Drawing.csproj
  92. 102 23
      Emby.Drawing/ImageProcessor.cs
  93. 1 1
      Emby.Drawing/NullImageEncoder.cs
  94. 12 3
      Emby.Naming/Audio/AlbumParser.cs
  95. 12 6
      Emby.Naming/Audio/AudioFileParser.cs
  96. 16 7
      Emby.Naming/AudioBook/AudioBookFileInfo.cs
  97. 12 5
      Emby.Naming/AudioBook/AudioBookFilePathParser.cs
  98. 9 5
      Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs
  99. 14 7
      Emby.Naming/AudioBook/AudioBookInfo.cs
  100. 117 12
      Emby.Naming/AudioBook/AudioBookListResolver.cs

+ 4 - 1
.ci/azure-pipelines-abi.yml

@@ -7,7 +7,7 @@ parameters:
   default: "ubuntu-latest"
 - name: DotNetSdkVersion
   type: string
-  default: 3.1.100
+  default: 5.0.302
 
 jobs:
   - job: CompatibilityCheck
@@ -62,6 +62,7 @@ jobs:
 
       - task: DownloadPipelineArtifact@2
         displayName: 'Download Reference Assembly Build Artifact'
+        enabled: false
         inputs:
           source: "specific"
           artifact: "$(NugetPackageName)"
@@ -73,6 +74,7 @@ jobs:
 
       - task: CopyFiles@2
         displayName: 'Copy Reference Assembly Build Artifact'
+        enabled: false
         inputs:
           sourceFolder: $(System.ArtifactsDirectory)/current-artifacts
           contents: '**/*.dll'
@@ -83,6 +85,7 @@ jobs:
 
       - task: DotNetCoreCLI@2
         displayName: 'Execute ABI Compatibility Check Tool'
+        enabled: false
         inputs:
           command: custom
           custom: compat

+ 0 - 78
.ci/azure-pipelines-api-client.yml

@@ -1,78 +0,0 @@
-parameters:
-  - name: LinuxImage
-    type: string
-    default: "ubuntu-latest"
-  - name: GeneratorVersion
-    type: string
-    default: "5.0.0-beta2"
-
-jobs:
-- job: GenerateApiClients
-  displayName: 'Generate Api Clients'
-  dependsOn: Test
-
-  pool:
-    vmImage: "${{ parameters.LinuxImage }}"
-
-  steps:
-    - task: DownloadPipelineArtifact@2
-      displayName: 'Download OpenAPI Spec Artifact'
-      inputs:
-        source: 'current'
-        artifact: "OpenAPI Spec"
-        path: "$(System.ArtifactsDirectory)/openapispec"
-        runVersion: "latest"
-
-    - task: CmdLine@2
-      displayName: 'Download OpenApi Generator'
-      inputs:
-        script: "wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${{ parameters.GeneratorVersion }}/openapi-generator-cli-${{ parameters.GeneratorVersion }}.jar -O openapi-generator-cli.jar"
-
-## Authenticate with npm registry
-    - task: npmAuthenticate@0
-      inputs:
-        workingFile: ./.npmrc
-        customEndpoint: 'jellyfin-bot for NPM'
-
-## Generate npm api client
-# Unstable
-    - task: CmdLine@2
-      displayName: 'Build unstable typescript axios client'
-      condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
-      inputs:
-        script: "bash ./apiclient/templates/typescript/axios/generate.sh $(System.ArtifactsDirectory) $(Build.BuildNumber)"
-
-# Stable
-    - task: CmdLine@2
-      displayName: 'Build stable typescript axios client'
-      condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
-      inputs:
-        script: "bash ./apiclient/templates/typescript/axios/generate.sh $(System.ArtifactsDirectory)"
-
-## Run npm install
-    - task: Npm@1
-      displayName: 'Install npm dependencies'
-      inputs:
-        command: install
-        workingDir: ./apiclient/generated/typescript/axios
-
-## Publish npm packages
-# Unstable
-    - task: Npm@1
-      displayName: 'Publish unstable typescript axios client'
-      condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
-      inputs:
-        command: publish
-        publishRegistry: useFeed
-        publishFeed: 'jellyfin/unstable'
-        workingDir: ./apiclient/generated/typescript/axios
-
-# Stable
-    - task: Npm@1
-      displayName: 'Publish stable typescript axios client'
-      condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
-      inputs:
-        command: publish
-        publishRegistry: useExternalRegistry
-        publishEndpoint: 'jellyfin-bot for NPM'
-        workingDir: ./apiclient/generated/typescript/axios

+ 1 - 1
.ci/azure-pipelines-main.yml

@@ -1,7 +1,7 @@
 parameters:
   LinuxImage: 'ubuntu-latest'
   RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj'
-  DotNetSdkVersion: 3.1.100
+  DotNetSdkVersion: 5.0.302
 
 jobs:
   - job: Build

+ 30 - 16
.ci/azure-pipelines-package.yml

@@ -22,6 +22,12 @@ jobs:
         BuildConfiguration: ubuntu.armhf
       Linux.amd64:
         BuildConfiguration: linux.amd64
+      Linux.amd64-musl:
+        BuildConfiguration: linux.amd64-musl
+      Linux.arm64:
+        BuildConfiguration: linux.arm64
+      Linux.armhf:
+        BuildConfiguration: linux.armhf
       Windows.amd64:
         BuildConfiguration: windows.amd64
       MacOS:
@@ -63,6 +69,7 @@ jobs:
       sshEndpoint: repository
       sourceFolder: '$(Build.SourcesDirectory)/deployment/dist'
       contents: '**'
+      targetFolder: '/srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)'
 
 - job: OpenAPISpec
   dependsOn: Test
@@ -153,7 +160,6 @@ jobs:
   dependsOn:
   - BuildPackage
   - BuildDocker
-  condition: and(succeeded('BuildPackage'), succeeded('BuildDocker'))
 
   pool:
     vmImage: 'ubuntu-latest'
@@ -166,7 +172,7 @@ jobs:
     inputs:
       sshEndpoint: repository
       runOptions: 'commands'
-      commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable &
+      commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable &
 
   - task: SSH@0
     displayName: 'Update Stable Repository'
@@ -175,28 +181,42 @@ jobs:
     inputs:
       sshEndpoint: repository
       runOptions: 'commands'
-      commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) &
+      commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) &
 
 - job: PublishNuget
   displayName: 'Publish NuGet packages'
-  dependsOn:
-  - BuildPackage
-  condition: succeeded('BuildPackage')
 
   pool:
     vmImage: 'ubuntu-latest'
 
+  variables:
+  - name: JellyfinVersion
+    value: $[replace(variables['Build.SourceBranch'],'refs/tags/v','')]
+
   steps:
+  - task: UseDotNet@2
+    displayName: 'Use .NET 5.0 sdk'
+    inputs:
+      packageType: 'sdk'
+      version: '5.0.x'
+
   - task: DotNetCoreCLI@2
     displayName: 'Build Stable Nuget packages'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
     inputs:
-      command: 'pack'
-      packagesToPack: 'Jellyfin.Data/Jellyfin.Data.csproj;MediaBrowser.Common/MediaBrowser.Common.csproj;MediaBrowser.Controller/MediaBrowser.Controller.csproj;MediaBrowser.Model/MediaBrowser.Model.csproj;Emby.Naming/Emby.Naming.csproj'
-      versioningScheme: 'off'
+      command: 'custom'
+      projects: |
+        Jellyfin.Data/Jellyfin.Data.csproj
+        MediaBrowser.Common/MediaBrowser.Common.csproj
+        MediaBrowser.Controller/MediaBrowser.Controller.csproj
+        MediaBrowser.Model/MediaBrowser.Model.csproj
+        Emby.Naming/Emby.Naming.csproj
+      custom: 'pack'
+      arguments: -o $(Build.ArtifactStagingDirectory) -p:Version=$(JellyfinVersion)
 
   - task: DotNetCoreCLI@2
     displayName: 'Build Unstable Nuget packages'
+    condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
     inputs:
       command: 'custom'
       projects: |
@@ -214,18 +234,12 @@ jobs:
       pathToPublish: $(Build.ArtifactStagingDirectory)
       artifactName: Jellyfin Nuget Packages
 
-  - task: NuGetAuthenticate@0
-    displayName: 'Authenticate to stable Nuget feed'
-    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
-    inputs:
-      nuGetServiceConnections: 'NugetOrg'
-
   - task: NuGetCommand@2
     displayName: 'Push Nuget packages to stable feed'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
     inputs:
       command: 'push'
-      packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg;$(Build.ArtifactStagingDirectory)/**/*.snupkg'
+      packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg'
       nuGetFeedType: 'external'
       publishFeedCredentials: 'NugetOrg'
       allowPackageConflicts: true # This ignores an error if the version already exists

+ 4 - 4
.ci/azure-pipelines-test.yml

@@ -10,7 +10,7 @@ parameters:
   default: "tests/**/*Tests.csproj"
 - name: DotNetSdkVersion
   type: string
-  default: 3.1.100
+  default: 5.0.302
 
 jobs:
   - job: Test
@@ -30,11 +30,11 @@ jobs:
 
       # This is required for the SonarCloud analyzer
       - task: UseDotNet@2
-        displayName: "Install .NET Core SDK 2.1"
+        displayName: "Install .NET SDK 5.x"
         condition: eq(variables['ImageName'], 'ubuntu-latest')
         inputs:
           packageType: sdk
-          version: '2.1.805'
+          version: '5.x'
 
       - task: UseDotNet@2
         displayName: "Update DotNet"
@@ -94,5 +94,5 @@ jobs:
         displayName: 'Publish OpenAPI Artifact'
         condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
         inputs:
-          targetPath: "tests/Jellyfin.Api.Tests/bin/Release/netcoreapp3.1/openapi.json"
+          targetPath: "tests/Jellyfin.Server.Integration.Tests/bin/Release/net5.0/openapi.json"
           artifactName: 'OpenAPI Spec'

+ 1 - 4
.ci/azure-pipelines.yml

@@ -6,7 +6,7 @@ variables:
 - name: RestoreBuildProjects
   value: 'Jellyfin.Server/Jellyfin.Server.csproj'
 - name: DotNetSdkVersion
-  value: 3.1.100
+  value: 5.0.302
 
 pr:
   autoCancel: true
@@ -61,6 +61,3 @@ jobs:
 
 - ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}:
   - template: azure-pipelines-package.yml
-
-- ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}:
-  - template: azure-pipelines-api-client.yml

+ 0 - 30
.drone.yml

@@ -1,30 +0,0 @@
----
-kind: pipeline
-name: build-debug
-
-steps:
-- name: submodules
-  image: docker:git
-  commands:
-    - git submodule update --init --recursive
-
-- name: build
-  image: microsoft/dotnet:2-sdk
-  commands:
-    - dotnet publish "Jellyfin.Server" --configuration Debug --output "../ci/ci-debug"
-
----
-kind: pipeline
-name: build-release
-
-steps:
-- name: submodules
-  image: docker:git
-  commands:
-    - git submodule update --init --recursive
-
-- name: build
-  image: microsoft/dotnet:2-sdk
-  commands:
-    - dotnet publish "Jellyfin.Server" --configuration Release --output "../ci/ci-release"
-

+ 1 - 0
.github/CODEOWNERS

@@ -1,3 +1,4 @@
 # Joshua must review all changes to deployment and build.sh
+.ci/*           @joshuaboniface
 deployment/*    @joshuaboniface
 build.sh        @joshuaboniface

+ 8 - 1
.github/ISSUE_TEMPLATE/bug_report.md

@@ -17,6 +17,7 @@ assignees: ''
  - Browser: [e.g. Firefox 72, Chrome 80, Safari 13]
  - Jellyfin Version: [e.g. 10.4.3, nightly 20191231]
  - Playback: [Direct Play, Remux, Direct Stream, Transcode] 
+ - Hardware Acceleration: [e.g. none, VAAPI, NVENC, etc.]
  - Installed Plugins: [e.g. none, Fanart, Anime, etc.]
  - Reverse Proxy: [e.g. none, nginx, apache, etc.]
  - Base URL: [e.g. none, yes: /example]
@@ -33,7 +34,13 @@ assignees: ''
 **Expected behavior**
 <!-- A clear and concise description of what you expected to happen. -->
 
-**Logs**
+**Server Logs**
+<!-- Please paste any log errors. -->
+
+**FFmpeg Logs**
+<!-- Please paste any log errors. -->
+
+**Browser Console Logs**
 <!-- Please paste any log errors. -->
 
 **Screenshots**

+ 7 - 1
.github/dependabot.yml

@@ -6,4 +6,10 @@ updates:
     interval: weekly
     time: '12:00'
   open-pull-requests-limit: 10
-  
+
+- package-ecosystem: github-actions
+  directory: '/'
+  schedule:
+    interval: weekly
+    time: '12:00'
+  open-pull-requests-limit: 10

+ 5 - 1
.github/stale.yml

@@ -17,9 +17,13 @@ staleLabel: stale
 # Comment to post when marking an issue as stale. Set to `false` to disable
 markComment: >
   This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
-  
+
   If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or nightlies, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
 
   This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).
 # Comment to post when closing a stale issue. Set to `false` to disable
 closeComment: false
+
+# Disable automatic closing of pull requests
+pulls:
+  daysUntilClose: false

+ 76 - 0
.github/workflows/automation.yml

@@ -0,0 +1,76 @@
+name: Automation
+
+on:
+  push:
+    branches:
+      - master
+  pull_request_target:
+  issue_comment:
+
+jobs:
+  label:
+    name: Labeling
+    runs-on: ubuntu-latest
+    if: ${{ github.repository == 'jellyfin/jellyfin' }}
+    steps:
+      - name: Apply label
+        uses: eps1lon/actions-label-merge-conflict@v2.0.1
+        if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
+        with:
+          dirtyLabel: 'merge conflict'
+          repoToken: ${{ secrets.JF_BOT_TOKEN }}
+
+  project:
+    name: Project board
+    runs-on: ubuntu-latest
+    if: ${{ github.repository == 'jellyfin/jellyfin' }}
+    steps:
+      - name: Remove from 'Current Release' project
+        uses: alex-page/github-project-automation-plus@v0.8.1
+        if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
+        continue-on-error: true
+        with:
+          project: Current Release
+          action: delete
+          repo-token: ${{ secrets.JF_BOT_TOKEN }}
+
+      - name: Add to 'Release Next' project
+        uses: alex-page/github-project-automation-plus@v0.8.1
+        if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened'
+        continue-on-error: true
+        with:
+          project: Release Next
+          column: In progress
+          repo-token: ${{ secrets.JF_BOT_TOKEN }}
+
+      - name: Add to 'Current Release' project
+        uses: alex-page/github-project-automation-plus@v0.8.1
+        if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
+        continue-on-error: true
+        with:
+          project: Current Release
+          column: In progress
+          repo-token: ${{ secrets.JF_BOT_TOKEN }}
+
+      - name: Check number of comments from the team member
+        if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER'
+        id: member_comments
+        run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)"
+
+      - name: Move issue to needs triage
+        uses: alex-page/github-project-automation-plus@v0.8.1
+        if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1
+        continue-on-error: true
+        with:
+          project: Issue Triage for Main Repo
+          column: Needs triage
+          repo-token: ${{ secrets.JF_BOT_TOKEN }}
+
+      - name: Add issue to triage project
+        uses: alex-page/github-project-automation-plus@v0.8.1
+        if: github.event.issue.pull_request == '' && github.event.action == 'opened'
+        continue-on-error: true
+        with:
+          project: Issue Triage for Main Repo
+          column: Pending response
+          repo-token: ${{ secrets.JF_BOT_TOKEN }}

+ 36 - 0
.github/workflows/codeql-analysis.yml

@@ -0,0 +1,36 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+  schedule:
+    - cron: '24 2 * * 4'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'csharp' ]
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+    - name: Setup .NET Core
+      uses: actions/setup-dotnet@v1
+      with:
+        dotnet-version: '5.0.x'
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      with:
+        languages: ${{ matrix.language }}
+        queries: +security-extended
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v1
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1

+ 119 - 0
.github/workflows/commands.yml

@@ -0,0 +1,119 @@
+name: Commands
+on:
+  issue_comment:
+    types:
+      - created
+      - edited
+  pull_request_target:
+    types:
+      - labeled
+      - synchronize
+
+jobs:
+  rebase:
+    name: Rebase
+    if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Notify as seen
+        uses: peter-evans/create-or-update-comment@v1.4.5
+        with:
+          token: ${{ secrets.JF_BOT_TOKEN }}
+          comment-id: ${{ github.event.comment.id }}
+          reactions: '+1'
+
+      - name: Checkout the latest code
+        uses: actions/checkout@v2
+        with:
+          token: ${{ secrets.JF_BOT_TOKEN }}
+          fetch-depth: 0
+
+      - name: Automatic Rebase
+        uses: cirrus-actions/rebase@1.4
+        env:
+          GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
+
+  check-backport:
+    name: Check Backport
+    if: ${{ ( github.event.issue.pull_request && contains(github.event.comment.body, '@jellyfin-bot check backport') ) || github.event.label.name == 'stable backport' || contains(github.event.pull_request.labels.*.name, 'stable backport' ) }}
+    runs-on: ubuntu-latest
+    steps:
+      - name: Notify as seen
+        uses: peter-evans/create-or-update-comment@v1.4.5
+        if: ${{ github.event.comment != null }}
+        with:
+          token: ${{ secrets.JF_BOT_TOKEN }}
+          comment-id: ${{ github.event.comment.id }}
+          reactions: eyes
+
+      - name: Checkout the latest code
+        uses: actions/checkout@v2
+        with:
+          token: ${{ secrets.JF_BOT_TOKEN }}
+          fetch-depth: 0
+
+      - name: Notify as running
+        id: comment_running
+        uses: peter-evans/create-or-update-comment@v1.4.5
+        if: ${{ github.event.comment != null }}
+        with:
+          token: ${{ secrets.JF_BOT_TOKEN }}
+          issue-number: ${{ github.event.issue.number }}
+          body: |
+            Running backport tests...
+
+      - name: Perform test backport
+        id: run_tests
+        run: |
+          set +o errexit
+          git config --global user.name "Jellyfin Bot"
+          git config --global user.email "team@jellyfin.org"
+          CURRENT_BRANCH="origin/${GITHUB_HEAD_REF}"
+          git checkout master
+          git merge --no-ff ${CURRENT_BRANCH}
+          MERGE_COMMIT_HASH=$( git log -q -1 | head -1 | awk '{ print $2 }' )
+          git fetch --all
+          CURRENT_STABLE=$( git branch -r | grep 'origin/release' | sort -rV | head -1 | awk -F '/' '{ print $NF }' )
+          stable_branch="Current stable release branch: ${CURRENT_STABLE}"
+          echo ${stable_branch}
+          echo ::set-output name=branch::${stable_branch}
+          git checkout -t origin/${CURRENT_STABLE} -b ${CURRENT_STABLE}
+          git cherry-pick -sx -m1 ${MERGE_COMMIT_HASH} &>output.txt
+          retcode=$?
+          cat output.txt | grep -v 'hint:'
+          output="$( grep -v 'hint:'  output.txt )"
+          output="${output//'%'/'%25'}"
+          output="${output//$'\n'/'%0A'}"
+          output="${output//$'\r'/'%0D'}" 
+          echo ::set-output name=output::$output
+          exit ${retcode}
+
+      - name: Notify with result success
+        uses: peter-evans/create-or-update-comment@v1.4.5
+        if: ${{ github.event.comment != null && success() }}
+        with:
+          token: ${{ secrets.JF_BOT_TOKEN }}
+          comment-id: ${{ steps.comment_running.outputs.comment-id }}
+          body: |
+            ${{ steps.run_tests.outputs.branch }}
+            Output from `git cherry-pick`:
+
+            ---
+
+            ${{ steps.run_tests.outputs.output }}
+          reactions: hooray
+
+      - name: Notify with result failure
+        uses: peter-evans/create-or-update-comment@v1.4.5
+        if: ${{ github.event.comment != null && failure() }}
+        with:
+          token: ${{ secrets.JF_BOT_TOKEN }}
+          comment-id: ${{ steps.comment_running.outputs.comment-id }}
+          body: |
+            ${{ steps.run_tests.outputs.branch }}
+            Output from `git cherry-pick`:
+
+            ---
+
+            ${{ steps.run_tests.outputs.output }}
+          reactions: confused

+ 1 - 0
.gitignore

@@ -268,6 +268,7 @@ doc/
 # Deployment artifacts
 dist
 *.exe
+*.dll
 
 # BenchmarkDotNet artifacts
 BenchmarkDotNet.Artifacts

+ 2 - 2
.vscode/launch.json

@@ -6,7 +6,7 @@
             "type": "coreclr",
             "request": "launch",
             "preLaunchTask": "build",
-            "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll",
+            "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll",
             "args": [],
             "cwd": "${workspaceFolder}/Jellyfin.Server",
             "console": "internalConsole",
@@ -22,7 +22,7 @@
             "type": "coreclr",
             "request": "launch",
             "preLaunchTask": "build",
-            "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll",
+            "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll",
             "args": ["--nowebclient"],
             "cwd": "${workspaceFolder}/Jellyfin.Server",
             "console": "internalConsole",

+ 11 - 0
CONTRIBUTORS.md

@@ -7,6 +7,7 @@
  - [anthonylavado](https://github.com/anthonylavado)
  - [Artiume](https://github.com/Artiume)
  - [AThomsen](https://github.com/AThomsen)
+ - [barongreenback](https://github.com/BaronGreenback)
  - [barronpm](https://github.com/barronpm)
  - [bilde2910](https://github.com/bilde2910)
  - [bfayers](https://github.com/bfayers)
@@ -16,6 +17,7 @@
  - [bugfixin](https://github.com/bugfixin)
  - [chaosinnovator](https://github.com/chaosinnovator)
  - [ckcr4lyf](https://github.com/ckcr4lyf)
+ - [cocool97](https://github.com/cocool97)
  - [ConfusedPolarBear](https://github.com/ConfusedPolarBear)
  - [crankdoofus](https://github.com/crankdoofus)
  - [crobibero](https://github.com/crobibero)
@@ -48,6 +50,7 @@
  - [h1nk](https://github.com/h1nk)
  - [hawken93](https://github.com/hawken93)
  - [HelloWorld017](https://github.com/HelloWorld017)
+ - [ikomhoog](https://github.com/ikomhoog)
  - [jftuga](https://github.com/jftuga)
  - [joern-h](https://github.com/joern-h)
  - [joshuaboniface](https://github.com/joshuaboniface)
@@ -67,6 +70,7 @@
  - [marius-luca-87](https://github.com/marius-luca-87)
  - [mark-monteiro](https://github.com/mark-monteiro)
  - [Matt07211](https://github.com/Matt07211)
+ - [Maxr1998](https://github.com/Maxr1998)
  - [mcarlton00](https://github.com/mcarlton00)
  - [mitchfizz05](https://github.com/mitchfizz05)
  - [MrTimscampi](https://github.com/MrTimscampi)
@@ -78,6 +82,8 @@
  - [Nickbert7](https://github.com/Nickbert7)
  - [nvllsvm](https://github.com/nvllsvm)
  - [nyanmisaka](https://github.com/nyanmisaka)
+ - [OancaAndrei](https://github.com/OancaAndrei)
+ - [obradovichv](https://github.com/obradovichv)
  - [oddstr13](https://github.com/oddstr13)
  - [orryverducci](https://github.com/orryverducci)
  - [petermcneil](https://github.com/petermcneil)
@@ -101,9 +107,11 @@
  - [shemanaev](https://github.com/shemanaev)
  - [skaro13](https://github.com/skaro13)
  - [sl1288](https://github.com/sl1288)
+ - [Smith00101010](https://github.com/Smith00101010)
  - [sorinyo2004](https://github.com/sorinyo2004)
  - [sparky8251](https://github.com/sparky8251)
  - [spookbits](https://github.com/spookbits)
+ - [ssenart](https://github.com/ssenart)
  - [stanionascu](https://github.com/stanionascu)
  - [stevehayles](https://github.com/stevehayles)
  - [SuperSandro2000](https://github.com/SuperSandro2000)
@@ -138,6 +146,8 @@
  - [Pusta](https://github.com/pusta)
  - [nielsvanvelzen](https://github.com/nielsvanvelzen)
  - [skyfrk](https://github.com/skyfrk)
+ - [ianjazz246](https://github.com/ianjazz246)
+ - [peterspenler](https://github.com/peterspenler)
 
 # Emby Contributors
 
@@ -202,3 +212,4 @@
  - [Tim Hobbs](https://github.com/timhobbs)
  - [SvenVandenbrande](https://github.com/SvenVandenbrande)
  - [olsh](https://github.com/olsh)
+ - [gnuyent](https://github.com/gnuyent)

+ 14 - 0
Directory.Build.props

@@ -0,0 +1,14 @@
+<Project>
+  <!-- Sets defaults for all projects in the repo -->
+
+  <PropertyGroup>
+    <Nullable>enable</Nullable>
+    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+    <CodeAnalysisRuleSet>$(MSBuildThisFileDirectory)/jellyfin.ruleset</CodeAnalysisRuleSet>
+  </PropertyGroup>
+
+  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+    <AnalysisMode>AllEnabledByDefault</AnalysisMode>
+  </PropertyGroup>
+
+</Project>

+ 39 - 16
Dockerfile

@@ -1,22 +1,14 @@
-ARG DOTNET_VERSION=3.1
+ARG DOTNET_VERSION=5.0
 
-FROM node:alpine as web-builder
+FROM node:lts-alpine as web-builder
 ARG JELLYFIN_WEB_VERSION=master
-RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python \
+RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \
  && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
  && cd jellyfin-web-* \
- && yarn install \
+ && npm ci --no-audit --unsafe-perm \
  && mv dist /dist
 
-FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster as builder
-WORKDIR /repo
-COPY . .
-ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
-# because of changes in docker and systemd we need to not build in parallel at the moment
-# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting
-RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 "-p:DebugSymbols=false;DebugType=none"
-
-FROM debian:buster-slim
+FROM debian:buster-slim as app
 
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 ARG DEBIAN_FRONTEND="noninteractive"
@@ -25,10 +17,14 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
 # https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support)
 ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 
-COPY --from=builder /jellyfin /jellyfin
-COPY --from=web-builder /dist /jellyfin/jellyfin-web
+# https://github.com/intel/compute-runtime/releases
+ARG GMMLIB_VERSION=20.3.2
+ARG IGC_VERSION=1.0.5435
+ARG NEO_VERSION=20.46.18421
+ARG LEVEL_ZERO_VERSION=1.0.18421
+
 # Install dependencies:
-#   mesa-va-drivers: needed for AMD VAAPI
+# mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding.
 RUN apt-get update \
  && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https \
  && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \
@@ -39,6 +35,20 @@ RUN apt-get update \
    jellyfin-ffmpeg \
    openssl \
    locales \
+# Intel VAAPI Tone mapping dependencies:
+# Prefer NEO to Beignet since the latter one doesn't support Comet Lake or newer for now.
+# Do not use the intel-opencl-icd package from repo since they will not build with RELEASE_WITH_REGKEYS enabled.
+ && mkdir intel-compute-runtime \
+ && cd intel-compute-runtime \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-gmmlib_${GMMLIB_VERSION}_amd64.deb \
+ && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-core_${IGC_VERSION}_amd64.deb \
+ && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-opencl_${IGC_VERSION}_amd64.deb \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-opencl_${NEO_VERSION}_amd64.deb \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-ocloc_${NEO_VERSION}_amd64.deb \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-level-zero-gpu_${LEVEL_ZERO_VERSION}_amd64.deb \
+ && dpkg -i *.deb \
+ && cd .. \
+ && rm -rf intel-compute-runtime \
  && apt-get remove gnupg wget apt-transport-https -y \
  && apt-get clean autoclean -y \
  && apt-get autoremove -y \
@@ -52,6 +62,19 @@ ENV LC_ALL en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANGUAGE en_US:en
 
+FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
+WORKDIR /repo
+COPY . .
+ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
+# because of changes in docker and systemd we need to not build in parallel at the moment
+# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting
+RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 "-p:DebugSymbols=false;DebugType=none"
+
+FROM app
+
+COPY --from=builder /jellyfin /jellyfin
+COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
 EXPOSE 8096
 VOLUME /cache /config /media
 ENTRYPOINT ["./jellyfin/jellyfin", \

+ 19 - 19
Dockerfile.arm

@@ -2,30 +2,19 @@
 #####################################
 # Requires binfm_misc registration
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
-ARG DOTNET_VERSION=3.1
+ARG DOTNET_VERSION=5.0
 
 
-FROM node:alpine as web-builder
+FROM node:lts-alpine as web-builder
 ARG JELLYFIN_WEB_VERSION=master
-RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python \
+RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \
  && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
  && cd jellyfin-web-* \
- && yarn install \
+ && npm ci --no-audit --unsafe-perm \
  && mv dist /dist
 
-
-FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION} as builder
-WORKDIR /repo
-COPY . .
-ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
-# Discard objs - may cause failures if exists
-RUN find . -type d -name obj | xargs -r rm -r
-# Build
-RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm "-p:DebugSymbols=false;DebugType=none"
-
-
 FROM multiarch/qemu-user-static:x86_64-arm as qemu
-FROM arm32v7/debian:buster-slim
+FROM arm32v7/debian:buster-slim as app
 
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 ARG DEBIAN_FRONTEND="noninteractive"
@@ -61,14 +50,25 @@ RUN apt-get update \
  && chmod 777 /cache /config /media \
  && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen
 
-COPY --from=builder /jellyfin /jellyfin
-COPY --from=web-builder /dist /jellyfin/jellyfin-web
-
 ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
 ENV LC_ALL en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANGUAGE en_US:en
 
+FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
+WORKDIR /repo
+COPY . .
+ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
+# Discard objs - may cause failures if exists
+RUN find . -type d -name obj | xargs -r rm -r
+# Build
+RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm "-p:DebugSymbols=false;DebugType=none"
+
+FROM app
+
+COPY --from=builder /jellyfin /jellyfin
+COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
 EXPOSE 8096
 VOLUME /cache /config /media
 ENTRYPOINT ["./jellyfin/jellyfin", \

+ 19 - 18
Dockerfile.arm64

@@ -2,29 +2,19 @@
 #####################################
 # Requires binfm_misc registration
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
-ARG DOTNET_VERSION=3.1
+ARG DOTNET_VERSION=5.0
 
 
-FROM node:alpine as web-builder
+FROM node:lts-alpine as web-builder
 ARG JELLYFIN_WEB_VERSION=master
-RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python \
+RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \
  && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
  && cd jellyfin-web-* \
- && yarn install \
+ && npm ci --no-audit --unsafe-perm \
  && mv dist /dist
 
-
-FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION} as builder
-WORKDIR /repo
-COPY . .
-ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
-# Discard objs - may cause failures if exists
-RUN find . -type d -name obj | xargs -r rm -r
-# Build
-RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 "-p:DebugSymbols=false;DebugType=none"
-
 FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu
-FROM arm64v8/debian:buster-slim
+FROM arm64v8/debian:buster-slim as app
 
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 ARG DEBIAN_FRONTEND="noninteractive"
@@ -50,14 +40,25 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge
  && chmod 777 /cache /config /media \
  && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen
 
-COPY --from=builder /jellyfin /jellyfin
-COPY --from=web-builder /dist /jellyfin/jellyfin-web
-
 ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
 ENV LC_ALL en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANGUAGE en_US:en
 
+FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
+WORKDIR /repo
+COPY . .
+ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
+# Discard objs - may cause failures if exists
+RUN find . -type d -name obj | xargs -r rm -r
+# Build
+RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 "-p:DebugSymbols=false;DebugType=none"
+
+FROM app
+
+COPY --from=builder /jellyfin /jellyfin
+COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
 EXPOSE 8096
 VOLUME /cache /config /media
 ENTRYPOINT ["./jellyfin/jellyfin", \

+ 3 - 2
DvdLib/DvdLib.csproj

@@ -10,10 +10,11 @@
   </ItemGroup>
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+    <AnalysisMode>AllDisabledByDefault</AnalysisMode>
+    <Nullable>disable</Nullable>
   </PropertyGroup>
 
 </Project>

+ 1 - 1
DvdLib/Ifo/Dvd.cs

@@ -31,7 +31,7 @@ namespace DvdLib.Ifo
                         continue;
                     }
 
-                    var nums = ifo.Name.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries);
+                    var nums = ifo.Name.Split('_', StringSplitOptions.RemoveEmptyEntries);
                     if (nums.Length >= 2 && ushort.TryParse(nums[1], out var ifoNumber))
                     {
                         ReadVTS(ifoNumber, ifo.FullName);

+ 15 - 5
Emby.Dlna/Common/Argument.cs

@@ -1,13 +1,23 @@
-#pragma warning disable CS1591
-
 namespace Emby.Dlna.Common
 {
+    /// <summary>
+    /// DLNA Query parameter type, used when querying DLNA devices via SOAP.
+    /// </summary>
     public class Argument
     {
-        public string Name { get; set; }
+        /// <summary>
+        /// Gets or sets name of the DLNA argument.
+        /// </summary>
+        public string Name { get; set; } = string.Empty;
 
-        public string Direction { get; set; }
+        /// <summary>
+        /// Gets or sets the direction of the parameter.
+        /// </summary>
+        public string Direction { get; set; } = string.Empty;
 
-        public string RelatedStateVariable { get; set; }
+        /// <summary>
+        /// Gets or sets the related DLNA state variable for this argument.
+        /// </summary>
+        public string RelatedStateVariable { get; set; } = string.Empty;
     }
 }

+ 22 - 10
Emby.Dlna/Common/DeviceIcon.cs

@@ -1,29 +1,41 @@
-#pragma warning disable CS1591
-
 using System.Globalization;
 
 namespace Emby.Dlna.Common
 {
+    /// <summary>
+    /// Defines the <see cref="DeviceIcon" />.
+    /// </summary>
     public class DeviceIcon
     {
-        public string Url { get; set; }
+        /// <summary>
+        /// Gets or sets the Url.
+        /// </summary>
+        public string Url { get; set; } = string.Empty;
 
-        public string MimeType { get; set; }
+        /// <summary>
+        /// Gets or sets the MimeType.
+        /// </summary>
+        public string MimeType { get; set; } = string.Empty;
 
+        /// <summary>
+        /// Gets or sets the Width.
+        /// </summary>
         public int Width { get; set; }
 
+        /// <summary>
+        /// Gets or sets the Height.
+        /// </summary>
         public int Height { get; set; }
 
-        public string Depth { get; set; }
+        /// <summary>
+        /// Gets or sets the Depth.
+        /// </summary>
+        public string Depth { get; set; } = string.Empty;
 
         /// <inheritdoc />
         public override string ToString()
         {
-            return string.Format(
-                CultureInfo.InvariantCulture,
-                "{0}x{1}",
-                Height,
-                Width);
+            return string.Format(CultureInfo.InvariantCulture, "{0}x{1}", Height, Width);
         }
     }
 }

+ 24 - 9
Emby.Dlna/Common/DeviceService.cs

@@ -1,21 +1,36 @@
-#pragma warning disable CS1591
-
 namespace Emby.Dlna.Common
 {
+    /// <summary>
+    /// Defines the <see cref="DeviceService" />.
+    /// </summary>
     public class DeviceService
     {
-        public string ServiceType { get; set; }
+        /// <summary>
+        /// Gets or sets the Service Type.
+        /// </summary>
+        public string ServiceType { get; set; } = string.Empty;
 
-        public string ServiceId { get; set; }
+        /// <summary>
+        /// Gets or sets the Service Id.
+        /// </summary>
+        public string ServiceId { get; set; } = string.Empty;
 
-        public string ScpdUrl { get; set; }
+        /// <summary>
+        /// Gets or sets the Scpd Url.
+        /// </summary>
+        public string ScpdUrl { get; set; } = string.Empty;
 
-        public string ControlUrl { get; set; }
+        /// <summary>
+        /// Gets or sets the Control Url.
+        /// </summary>
+        public string ControlUrl { get; set; } = string.Empty;
 
-        public string EventSubUrl { get; set; }
+        /// <summary>
+        /// Gets or sets the EventSubUrl.
+        /// </summary>
+        public string EventSubUrl { get; set; } = string.Empty;
 
         /// <inheritdoc />
-        public override string ToString()
-            => ServiceId;
+        public override string ToString() => ServiceId;
     }
 }

+ 14 - 7
Emby.Dlna/Common/ServiceAction.cs

@@ -1,24 +1,31 @@
-#pragma warning disable CS1591
-
 using System.Collections.Generic;
 
 namespace Emby.Dlna.Common
 {
+    /// <summary>
+    /// Defines the <see cref="ServiceAction" />.
+    /// </summary>
     public class ServiceAction
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ServiceAction"/> class.
+        /// </summary>
         public ServiceAction()
         {
             ArgumentList = new List<Argument>();
         }
 
-        public string Name { get; set; }
+        /// <summary>
+        /// Gets or sets the name of the action.
+        /// </summary>
+        public string Name { get; set; } = string.Empty;
 
+        /// <summary>
+        /// Gets the ArgumentList.
+        /// </summary>
         public List<Argument> ArgumentList { get; }
 
         /// <inheritdoc />
-        public override string ToString()
-        {
-            return Name;
-        }
+        public override string ToString() => Name;
     }
 }

+ 19 - 12
Emby.Dlna/Common/StateVariable.cs

@@ -1,27 +1,34 @@
-#pragma warning disable CS1591
-
 using System;
 using System.Collections.Generic;
 
 namespace Emby.Dlna.Common
 {
+    /// <summary>
+    /// Defines the <see cref="StateVariable" />.
+    /// </summary>
     public class StateVariable
     {
-        public StateVariable()
-        {
-            AllowedValues = Array.Empty<string>();
-        }
-
-        public string Name { get; set; }
+        /// <summary>
+        /// Gets or sets the name of the state variable.
+        /// </summary>
+        public string Name { get; set; } = string.Empty;
 
-        public string DataType { get; set; }
+        /// <summary>
+        /// Gets or sets the data type of the state variable.
+        /// </summary>
+        public string DataType { get; set; } = string.Empty;
 
+        /// <summary>
+        /// Gets or sets a value indicating whether it sends events.
+        /// </summary>
         public bool SendsEvents { get; set; }
 
-        public IReadOnlyList<string> AllowedValues { get; set; }
+        /// <summary>
+        /// Gets or sets the allowed values range.
+        /// </summary>
+        public IReadOnlyList<string> AllowedValues { get; set; } = Array.Empty<string>();
 
         /// <inheritdoc />
-        public override string ToString()
-            => Name;
+        public override string ToString() => Name;
     }
 }

+ 65 - 6
Emby.Dlna/Configuration/DlnaOptions.cs

@@ -2,8 +2,14 @@
 
 namespace Emby.Dlna.Configuration
 {
+    /// <summary>
+    /// The DlnaOptions class contains the user definable parameters for the dlna subsystems.
+    /// </summary>
     public class DlnaOptions
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DlnaOptions"/> class.
+        /// </summary>
         public DlnaOptions()
         {
             EnablePlayTo = true;
@@ -11,23 +17,76 @@ namespace Emby.Dlna.Configuration
             BlastAliveMessages = true;
             SendOnlyMatchedHost = true;
             ClientDiscoveryIntervalSeconds = 60;
-            BlastAliveMessageIntervalSeconds = 1800;
+            AliveMessageIntervalSeconds = 1800;
         }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna playTo subsystem.
+        /// </summary>
         public bool EnablePlayTo { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna server subsystem.
+        /// </summary>
         public bool EnableServer { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether detailed dlna server logs are sent to the console/log.
+        /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work.
+        /// </summary>
         public bool EnableDebugLog { get; set; }
 
-        public bool BlastAliveMessages { get; set; }
-
-        public bool SendOnlyMatchedHost { get; set; }
+        /// <summary>
+        /// Gets or sets a value indicating whether whether detailed playTo debug logs are sent to the console/log.
+        /// If the setting "Emby.Dlna.PlayTo": "Debug" msut be set in logging.default.json for this property to work.
+        /// </summary>
+        public bool EnablePlayToTracing { get; set; }
 
+        /// <summary>
+        /// Gets or sets the ssdp client discovery interval time (in seconds).
+        /// This is the time after which the server will send a ssdp search request.
+        /// </summary>
         public int ClientDiscoveryIntervalSeconds { get; set; }
 
-        public int BlastAliveMessageIntervalSeconds { get; set; }
+        /// <summary>
+        /// Gets or sets the frequency at which ssdp alive notifications are transmitted.
+        /// </summary>
+        public int AliveMessageIntervalSeconds { get; set; }
+
+        /// <summary>
+        /// Gets or sets the frequency at which ssdp alive notifications are transmitted. MIGRATING - TO BE REMOVED ONCE WEB HAS BEEN ALTERED.
+        /// </summary>
+        public int BlastAliveMessageIntervalSeconds
+        {
+            get
+            {
+                return AliveMessageIntervalSeconds;
+            }
+
+            set
+            {
+                AliveMessageIntervalSeconds = value;
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the default user account that the dlna server uses.
+        /// </summary>
+        public string? DefaultUserId { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether playTo device profiles should be created.
+        /// </summary>
+        public bool AutoCreatePlayToProfiles { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to blast alive messages.
+        /// </summary>
+        public bool BlastAliveMessages { get; set; } = true;
 
-        public string DefaultUserId { get; set; }
+        /// <summary>
+        /// gets or sets a value indicating whether to send only matched host.
+        /// </summary>
+        public bool SendOnlyMatchedHost { get; set; } = true;
     }
 }

+ 0 - 1
Emby.Dlna/ConfigurationExtension.cs

@@ -1,4 +1,3 @@
-#nullable enable
 #pragma warning disable CS1591
 
 using Emby.Dlna.Configuration;

+ 11 - 1
Emby.Dlna/ConnectionManager/ConnectionManagerService.cs

@@ -9,11 +9,21 @@ using Microsoft.Extensions.Logging;
 
 namespace Emby.Dlna.ConnectionManager
 {
+    /// <summary>
+    /// Defines the <see cref="ConnectionManagerService" />.
+    /// </summary>
     public class ConnectionManagerService : BaseService, IConnectionManager
     {
         private readonly IDlnaManager _dlna;
         private readonly IServerConfigurationManager _config;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ConnectionManagerService"/> class.
+        /// </summary>
+        /// <param name="dlna">The <see cref="IDlnaManager"/> for use with the <see cref="ConnectionManagerService"/> instance.</param>
+        /// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="ConnectionManagerService"/> instance.</param>
+        /// <param name="logger">The <see cref="ILogger{ConnectionManagerService}"/> for use with the <see cref="ConnectionManagerService"/> instance..</param>
+        /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for use with the <see cref="ConnectionManagerService"/> instance..</param>
         public ConnectionManagerService(
             IDlnaManager dlna,
             IServerConfigurationManager config,
@@ -28,7 +38,7 @@ namespace Emby.Dlna.ConnectionManager
         /// <inheritdoc />
         public string GetServiceXml()
         {
-            return new ConnectionManagerXmlBuilder().GetXml();
+            return ConnectionManagerXmlBuilder.GetXml();
         }
 
         /// <inheritdoc />

+ 79 - 66
Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs

@@ -6,45 +6,57 @@ using Emby.Dlna.Service;
 
 namespace Emby.Dlna.ConnectionManager
 {
-    public class ConnectionManagerXmlBuilder
+    /// <summary>
+    /// Defines the <see cref="ConnectionManagerXmlBuilder" />.
+    /// </summary>
+    public static class ConnectionManagerXmlBuilder
     {
-        public string GetXml()
+        /// <summary>
+        /// Gets the ConnectionManager:1 service template.
+        /// See http://upnp.org/specs/av/UPnP-av-ConnectionManager-v1-Service.pdf.
+        /// </summary>
+        /// <returns>An XML description of this service.</returns>
+        public static string GetXml()
         {
-            return new ServiceXmlBuilder().GetXml(new ServiceActionListBuilder().GetActions(), GetStateVariables());
+            return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables());
         }
 
+        /// <summary>
+        /// Get the list of state variables for this invocation.
+        /// </summary>
+        /// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns>
         private static IEnumerable<StateVariable> GetStateVariables()
         {
-            var list = new List<StateVariable>();
-
-            list.Add(new StateVariable
+            var list = new List<StateVariable>
             {
-                Name = "SourceProtocolInfo",
-                DataType = "string",
-                SendsEvents = true
-            });
+                new StateVariable
+                {
+                    Name = "SourceProtocolInfo",
+                    DataType = "string",
+                    SendsEvents = true
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "SinkProtocolInfo",
-                DataType = "string",
-                SendsEvents = true
-            });
+                new StateVariable
+                {
+                    Name = "SinkProtocolInfo",
+                    DataType = "string",
+                    SendsEvents = true
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "CurrentConnectionIDs",
-                DataType = "string",
-                SendsEvents = true
-            });
+                new StateVariable
+                {
+                    Name = "CurrentConnectionIDs",
+                    DataType = "string",
+                    SendsEvents = true
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_ConnectionStatus",
-                DataType = "string",
-                SendsEvents = false,
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_ConnectionStatus",
+                    DataType = "string",
+                    SendsEvents = false,
 
-                AllowedValues = new[]
+                    AllowedValues = new[]
                 {
                     "OK",
                     "ContentFormatMismatch",
@@ -52,55 +64,56 @@ namespace Emby.Dlna.ConnectionManager
                     "UnreliableChannel",
                     "Unknown"
                 }
-            });
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_ConnectionManager",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_ConnectionManager",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_Direction",
-                DataType = "string",
-                SendsEvents = false,
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_Direction",
+                    DataType = "string",
+                    SendsEvents = false,
 
-                AllowedValues = new[]
+                    AllowedValues = new[]
                 {
                     "Output",
                     "Input"
                 }
-            });
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_ProtocolInfo",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_ProtocolInfo",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_ConnectionID",
-                DataType = "ui4",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_ConnectionID",
+                    DataType = "ui4",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_AVTransportID",
-                DataType = "ui4",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_AVTransportID",
+                    DataType = "ui4",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_RcsID",
-                DataType = "ui4",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_RcsID",
+                    DataType = "ui4",
+                    SendsEvents = false
+                }
+            };
 
             return list;
         }

+ 14 - 1
Emby.Dlna/ConnectionManager/ControlHandler.cs

@@ -11,10 +11,19 @@ using Microsoft.Extensions.Logging;
 
 namespace Emby.Dlna.ConnectionManager
 {
+    /// <summary>
+    /// Defines the <see cref="ControlHandler" />.
+    /// </summary>
     public class ControlHandler : BaseControlHandler
     {
         private readonly DeviceProfile _profile;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ControlHandler"/> class.
+        /// </summary>
+        /// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="ControlHandler"/> instance.</param>
+        /// <param name="logger">The <see cref="ILogger"/> for use with the <see cref="ControlHandler"/> instance.</param>
+        /// <param name="profile">The <see cref="DeviceProfile"/> for use with the <see cref="ControlHandler"/> instance.</param>
         public ControlHandler(IServerConfigurationManager config, ILogger logger, DeviceProfile profile)
             : base(config, logger)
         {
@@ -22,7 +31,7 @@ namespace Emby.Dlna.ConnectionManager
         }
 
         /// <inheritdoc />
-        protected override void WriteResult(string methodName, IDictionary<string, string> methodParams, XmlWriter xmlWriter)
+        protected override void WriteResult(string methodName, IReadOnlyDictionary<string, string> methodParams, XmlWriter xmlWriter)
         {
             if (string.Equals(methodName, "GetProtocolInfo", StringComparison.OrdinalIgnoreCase))
             {
@@ -33,6 +42,10 @@ namespace Emby.Dlna.ConnectionManager
             throw new ResourceNotFoundException("Unexpected control request name: " + methodName);
         }
 
+        /// <summary>
+        /// Builds the response to the GetProtocolInfo request.
+        /// </summary>
+        /// <param name="xmlWriter">The <see cref="XmlWriter"/>.</param>
         private void HandleGetProtocolInfo(XmlWriter xmlWriter)
         {
             xmlWriter.WriteElementString("Source", _profile.ProtocolInfo);

+ 32 - 5
Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs

@@ -5,9 +5,16 @@ using Emby.Dlna.Common;
 
 namespace Emby.Dlna.ConnectionManager
 {
-    public class ServiceActionListBuilder
+    /// <summary>
+    /// Defines the <see cref="ServiceActionListBuilder" />.
+    /// </summary>
+    public static class ServiceActionListBuilder
     {
-        public IEnumerable<ServiceAction> GetActions()
+        /// <summary>
+        /// Returns an enumerable of the ConnectionManagar:1 DLNA actions.
+        /// </summary>
+        /// <returns>An <see cref="IEnumerable{ServiceAction}"/>.</returns>
+        public static IEnumerable<ServiceAction> GetActions()
         {
             var list = new List<ServiceAction>
             {
@@ -21,6 +28,10 @@ namespace Emby.Dlna.ConnectionManager
             return list;
         }
 
+        /// <summary>
+        /// Returns the action details for "PrepareForConnection".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
         private static ServiceAction PrepareForConnection()
         {
             var action = new ServiceAction
@@ -80,6 +91,10 @@ namespace Emby.Dlna.ConnectionManager
             return action;
         }
 
+        /// <summary>
+        /// Returns the action details for "GetCurrentConnectionInfo".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
         private static ServiceAction GetCurrentConnectionInfo()
         {
             var action = new ServiceAction
@@ -146,7 +161,11 @@ namespace Emby.Dlna.ConnectionManager
             return action;
         }
 
-        private ServiceAction GetProtocolInfo()
+        /// <summary>
+        /// Returns the action details for "GetProtocolInfo".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
+        private static ServiceAction GetProtocolInfo()
         {
             var action = new ServiceAction
             {
@@ -170,7 +189,11 @@ namespace Emby.Dlna.ConnectionManager
             return action;
         }
 
-        private ServiceAction GetCurrentConnectionIDs()
+        /// <summary>
+        /// Returns the action details for "GetCurrentConnectionIDs".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
+        private static ServiceAction GetCurrentConnectionIDs()
         {
             var action = new ServiceAction
             {
@@ -187,7 +210,11 @@ namespace Emby.Dlna.ConnectionManager
             return action;
         }
 
-        private ServiceAction ConnectionComplete()
+        /// <summary>
+        /// Returns the action details for "ConnectionComplete".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
+        private static ServiceAction ConnectionComplete()
         {
             var action = new ServiceAction
             {

+ 36 - 5
Emby.Dlna/ContentDirectory/ContentDirectoryService.cs

@@ -19,6 +19,9 @@ using Microsoft.Extensions.Logging;
 
 namespace Emby.Dlna.ContentDirectory
 {
+    /// <summary>
+    /// Defines the <see cref="ContentDirectoryService" />.
+    /// </summary>
     public class ContentDirectoryService : BaseService, IContentDirectory
     {
         private readonly ILibraryManager _libraryManager;
@@ -33,6 +36,22 @@ namespace Emby.Dlna.ContentDirectory
         private readonly IMediaEncoder _mediaEncoder;
         private readonly ITVSeriesManager _tvSeriesManager;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ContentDirectoryService"/> class.
+        /// </summary>
+        /// <param name="dlna">The <see cref="IDlnaManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="userDataManager">The <see cref="IUserDataManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="imageProcessor">The <see cref="IImageProcessor"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="libraryManager">The <see cref="ILibraryManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="config">The <see cref="IServerConfigurationManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="userManager">The <see cref="IUserManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="logger">The <see cref="ILogger{ContentDirectoryService}"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="httpClient">The <see cref="IHttpClientFactory"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="localization">The <see cref="ILocalizationManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="userViewManager">The <see cref="IUserViewManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="mediaEncoder">The <see cref="IMediaEncoder"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
+        /// <param name="tvSeriesManager">The <see cref="ITVSeriesManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
         public ContentDirectoryService(
             IDlnaManager dlna,
             IUserDataManager userDataManager,
@@ -62,7 +81,10 @@ namespace Emby.Dlna.ContentDirectory
             _tvSeriesManager = tvSeriesManager;
         }
 
-        private int SystemUpdateId
+        /// <summary>
+        /// Gets the system id. (A unique id which changes on when our definition changes.)
+        /// </summary>
+        private static int SystemUpdateId
         {
             get
             {
@@ -75,14 +97,18 @@ namespace Emby.Dlna.ContentDirectory
         /// <inheritdoc />
         public string GetServiceXml()
         {
-            return new ContentDirectoryXmlBuilder().GetXml();
+            return ContentDirectoryXmlBuilder.GetXml();
         }
 
         /// <inheritdoc />
         public Task<ControlResponse> ProcessControlRequestAsync(ControlRequest request)
         {
-            var profile = _dlna.GetProfile(request.Headers) ??
-                          _dlna.GetDefaultProfile();
+            if (request == null)
+            {
+                throw new ArgumentNullException(nameof(request));
+            }
+
+            var profile = _dlna.GetProfile(request.Headers) ?? _dlna.GetDefaultProfile();
 
             var serverAddress = request.RequestedUrl.Substring(0, request.RequestedUrl.IndexOf("/dlna", StringComparison.OrdinalIgnoreCase));
 
@@ -107,7 +133,12 @@ namespace Emby.Dlna.ContentDirectory
                 .ProcessControlRequestAsync(request);
         }
 
-        private User GetUser(DeviceProfile profile)
+        /// <summary>
+        /// Get the user stored in the device profile.
+        /// </summary>
+        /// <param name="profile">The <see cref="DeviceProfile"/>.</param>
+        /// <returns>The <see cref="User"/>.</returns>
+        private User? GetUser(DeviceProfile profile)
         {
             if (!string.IsNullOrEmpty(profile.UserId))
             {

+ 120 - 109
Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs

@@ -6,143 +6,154 @@ using Emby.Dlna.Service;
 
 namespace Emby.Dlna.ContentDirectory
 {
-    public class ContentDirectoryXmlBuilder
+    /// <summary>
+    /// Defines the <see cref="ContentDirectoryXmlBuilder" />.
+    /// </summary>
+    public static class ContentDirectoryXmlBuilder
     {
-        public string GetXml()
+        /// <summary>
+        /// Gets the ContentDirectory:1 service template.
+        /// See http://upnp.org/specs/av/UPnP-av-ContentDirectory-v1-Service.pdf.
+        /// </summary>
+        /// <returns>An XML description of this service.</returns>
+        public static string GetXml()
         {
-            return new ServiceXmlBuilder().GetXml(
-                new ServiceActionListBuilder().GetActions(),
-                GetStateVariables());
+            return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables());
         }
 
+        /// <summary>
+        /// Get the list of state variables for this invocation.
+        /// </summary>
+        /// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns>
         private static IEnumerable<StateVariable> GetStateVariables()
         {
-            var list = new List<StateVariable>();
-
-            list.Add(new StateVariable
+            var list = new List<StateVariable>
             {
-                Name = "A_ARG_TYPE_Filter",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_Filter",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_SortCriteria",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_SortCriteria",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_Index",
-                DataType = "ui4",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_Index",
+                    DataType = "ui4",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_Count",
-                DataType = "ui4",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_Count",
+                    DataType = "ui4",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_UpdateID",
-                DataType = "ui4",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_UpdateID",
+                    DataType = "ui4",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "SearchCapabilities",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "SearchCapabilities",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "SortCapabilities",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "SortCapabilities",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "SystemUpdateID",
-                DataType = "ui4",
-                SendsEvents = true
-            });
+                new StateVariable
+                {
+                    Name = "SystemUpdateID",
+                    DataType = "ui4",
+                    SendsEvents = true
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_SearchCriteria",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_SearchCriteria",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_Result",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_Result",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_ObjectID",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_ObjectID",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_BrowseFlag",
-                DataType = "string",
-                SendsEvents = false,
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_BrowseFlag",
+                    DataType = "string",
+                    SendsEvents = false,
 
-                AllowedValues = new[]
+                    AllowedValues = new[]
                 {
                     "BrowseMetadata",
                     "BrowseDirectChildren"
                 }
-            });
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_BrowseLetter",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_BrowseLetter",
+                    DataType = "string",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_CategoryType",
-                DataType = "ui4",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_CategoryType",
+                    DataType = "ui4",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_RID",
-                DataType = "ui4",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_RID",
+                    DataType = "ui4",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_PosSec",
-                DataType = "ui4",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_PosSec",
+                    DataType = "ui4",
+                    SendsEvents = false
+                },
 
-            list.Add(new StateVariable
-            {
-                Name = "A_ARG_TYPE_Featurelist",
-                DataType = "string",
-                SendsEvents = false
-            });
+                new StateVariable
+                {
+                    Name = "A_ARG_TYPE_Featurelist",
+                    DataType = "string",
+                    SendsEvents = false
+                }
+            };
 
             return list;
         }

Fichier diff supprimé car celui-ci est trop grand
+ 439 - 131
Emby.Dlna/ContentDirectory/ControlHandler.cs


+ 13 - 0
Emby.Dlna/ContentDirectory/ServerItem.cs

@@ -4,8 +4,15 @@ using MediaBrowser.Controller.Entities;
 
 namespace Emby.Dlna.ContentDirectory
 {
+    /// <summary>
+    /// Defines the <see cref="ServerItem" />.
+    /// </summary>
     internal class ServerItem
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ServerItem"/> class.
+        /// </summary>
+        /// <param name="item">The <see cref="BaseItem"/>.</param>
         public ServerItem(BaseItem item)
         {
             Item = item;
@@ -16,8 +23,14 @@ namespace Emby.Dlna.ContentDirectory
             }
         }
 
+        /// <summary>
+        /// Gets or sets the underlying base item.
+        /// </summary>
         public BaseItem Item { get; set; }
 
+        /// <summary>
+        /// Gets or sets the DLNA item type.
+        /// </summary>
         public StubType? StubType { get; set; }
     }
 }

+ 44 - 7
Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs

@@ -1,13 +1,18 @@
-#pragma warning disable CS1591
-
 using System.Collections.Generic;
 using Emby.Dlna.Common;
 
 namespace Emby.Dlna.ContentDirectory
 {
-    public class ServiceActionListBuilder
+    /// <summary>
+    /// Defines the <see cref="ServiceActionListBuilder" />.
+    /// </summary>
+    public static class ServiceActionListBuilder
     {
-        public IEnumerable<ServiceAction> GetActions()
+        /// <summary>
+        /// Returns a list of services that this instance provides.
+        /// </summary>
+        /// <returns>An <see cref="IEnumerable{ServiceAction}"/>.</returns>
+        public static IEnumerable<ServiceAction> GetActions()
         {
             return new[]
             {
@@ -22,6 +27,10 @@ namespace Emby.Dlna.ContentDirectory
             };
         }
 
+        /// <summary>
+        /// Returns the action details for "GetSystemUpdateID".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
         private static ServiceAction GetGetSystemUpdateIDAction()
         {
             var action = new ServiceAction
@@ -39,6 +48,10 @@ namespace Emby.Dlna.ContentDirectory
             return action;
         }
 
+        /// <summary>
+        /// Returns the action details for "GetSearchCapabilities".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
         private static ServiceAction GetSearchCapabilitiesAction()
         {
             var action = new ServiceAction
@@ -56,6 +69,10 @@ namespace Emby.Dlna.ContentDirectory
             return action;
         }
 
+        /// <summary>
+        /// Returns the action details for "GetSortCapabilities".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
         private static ServiceAction GetSortCapabilitiesAction()
         {
             var action = new ServiceAction
@@ -73,6 +90,10 @@ namespace Emby.Dlna.ContentDirectory
             return action;
         }
 
+        /// <summary>
+        /// Returns the action details for "X_GetFeatureList".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
         private static ServiceAction GetX_GetFeatureListAction()
         {
             var action = new ServiceAction
@@ -90,6 +111,10 @@ namespace Emby.Dlna.ContentDirectory
             return action;
         }
 
+        /// <summary>
+        /// Returns the action details for "Search".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
         private static ServiceAction GetSearchAction()
         {
             var action = new ServiceAction
@@ -170,7 +195,11 @@ namespace Emby.Dlna.ContentDirectory
             return action;
         }
 
-        private ServiceAction GetBrowseAction()
+        /// <summary>
+        /// Returns the action details for "Browse".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
+        private static ServiceAction GetBrowseAction()
         {
             var action = new ServiceAction
             {
@@ -250,7 +279,11 @@ namespace Emby.Dlna.ContentDirectory
             return action;
         }
 
-        private ServiceAction GetBrowseByLetterAction()
+        /// <summary>
+        /// Returns the action details for "X_BrowseByLetter".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
+        private static ServiceAction GetBrowseByLetterAction()
         {
             var action = new ServiceAction
             {
@@ -337,7 +370,11 @@ namespace Emby.Dlna.ContentDirectory
             return action;
         }
 
-        private ServiceAction GetXSetBookmarkAction()
+        /// <summary>
+        /// Returns the action details for "X_SetBookmark".
+        /// </summary>
+        /// <returns>The <see cref="ServiceAction"/>.</returns>
+        private static ServiceAction GetXSetBookmarkAction()
         {
             var action = new ServiceAction
             {

+ 3 - 1
Emby.Dlna/ContentDirectory/StubType.cs

@@ -1,8 +1,10 @@
 #pragma warning disable CS1591
-#pragma warning disable SA1602
 
 namespace Emby.Dlna.ContentDirectory
 {
+    /// <summary>
+    /// Defines the DLNA item types.
+    /// </summary>
     public enum StubType
     {
         Folder = 0,

+ 2 - 0
Emby.Dlna/ControlRequest.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System.IO;

+ 3 - 1
Emby.Dlna/ControlResponse.cs

@@ -6,9 +6,11 @@ namespace Emby.Dlna
 {
     public class ControlResponse
     {
-        public ControlResponse()
+        public ControlResponse(string xml, bool isSuccessful)
         {
             Headers = new Dictionary<string, string>();
+            Xml = xml;
+            IsSuccessful = isSuccessful;
         }
 
         public IDictionary<string, string> Headers { get; }

+ 28 - 12
Emby.Dlna/Didl/DidlBuilder.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;
@@ -96,6 +98,7 @@ namespace Emby.Dlna.Didl
 
             using (StringWriter builder = new StringWriterWithEncoding(Encoding.UTF8))
             {
+                // If this using are changed to single lines, then write.Flush needs to be appended before the return.
                 using (var writer = XmlWriter.Create(builder, settings))
                 {
                     // writer.WriteStartDocument();
@@ -123,7 +126,7 @@ namespace Emby.Dlna.Didl
         {
             foreach (var att in profile.XmlRootAttributes)
             {
-                var parts = att.Name.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries);
+                var parts = att.Name.Split(':', StringSplitOptions.RemoveEmptyEntries);
                 if (parts.Length == 2)
                 {
                     writer.WriteAttributeString(parts[0], parts[1], null, att.Value);
@@ -207,7 +210,8 @@ namespace Emby.Dlna.Didl
             var targetWidth = streamInfo.TargetWidth;
             var targetHeight = streamInfo.TargetHeight;
 
-            var contentFeatureList = new ContentFeatureBuilder(_profile).BuildVideoHeader(
+            var contentFeatureList = ContentFeatureBuilder.BuildVideoHeader(
+                _profile,
                 streamInfo.Container,
                 streamInfo.TargetVideoCodec.FirstOrDefault(),
                 streamInfo.TargetAudioCodec.FirstOrDefault(),
@@ -598,7 +602,8 @@ namespace Emby.Dlna.Didl
                 ? MimeTypes.GetMimeType(filename)
                 : mediaProfile.MimeType;
 
-            var contentFeatures = new ContentFeatureBuilder(_profile).BuildAudioHeader(
+            var contentFeatures = ContentFeatureBuilder.BuildAudioHeader(
+                _profile,
                 streamInfo.Container,
                 streamInfo.TargetAudioCodec.FirstOrDefault(),
                 targetAudioBitrate,
@@ -973,15 +978,28 @@ namespace Emby.Dlna.Didl
                 return;
             }
 
-            var albumartUrlInfo = GetImageUrl(imageInfo, _profile.MaxAlbumArtWidth, _profile.MaxAlbumArtHeight, "jpg");
+            // TODO: Remove these default values
+            var albumArtUrlInfo = GetImageUrl(
+                imageInfo,
+                _profile.MaxAlbumArtWidth ?? 10000,
+                _profile.MaxAlbumArtHeight ?? 10000,
+                "jpg");
 
             writer.WriteStartElement("upnp", "albumArtURI", NsUpnp);
-            writer.WriteAttributeString("dlna", "profileID", NsDlna, _profile.AlbumArtPn);
-            writer.WriteString(albumartUrlInfo.url);
+            if (!string.IsNullOrEmpty(_profile.AlbumArtPn))
+            {
+                writer.WriteAttributeString("dlna", "profileID", NsDlna, _profile.AlbumArtPn);
+            }
+
+            writer.WriteString(albumArtUrlInfo.url);
             writer.WriteFullEndElement();
 
-            // TOOD: Remove these default values
-            var iconUrlInfo = GetImageUrl(imageInfo, _profile.MaxIconWidth ?? 48, _profile.MaxIconHeight ?? 48, "jpg");
+            // TODO: Remove these default values
+            var iconUrlInfo = GetImageUrl(
+                imageInfo,
+                _profile.MaxIconWidth ?? 48,
+                _profile.MaxIconHeight ?? 48,
+                "jpg");
             writer.WriteElementString("upnp", "icon", NsUpnp, iconUrlInfo.url);
 
             if (!_profile.EnableAlbumArtInDidl)
@@ -1032,8 +1050,7 @@ namespace Emby.Dlna.Didl
             var width = albumartUrlInfo.width ?? maxWidth;
             var height = albumartUrlInfo.height ?? maxHeight;
 
-            var contentFeatures = new ContentFeatureBuilder(_profile)
-                .BuildImageHeader(format, width, height, imageInfo.IsDirectStream, org_Pn);
+            var contentFeatures = ContentFeatureBuilder.BuildImageHeader(_profile, format, width, height, imageInfo.IsDirectStream, org_Pn);
 
             writer.WriteAttributeString(
                 "protocolInfo",
@@ -1205,8 +1222,7 @@ namespace Emby.Dlna.Didl
 
             if (width.HasValue && height.HasValue)
             {
-                var newSize = DrawingUtils.Resize(
-                        new ImageDimensions(width.Value, height.Value), 0, 0, maxWidth, maxHeight);
+                var newSize = DrawingUtils.Resize(new ImageDimensions(width.Value, height.Value), 0, 0, maxWidth, maxHeight);
 
                 width = newSize.Width;
                 height = newSize.Height;

+ 1 - 1
Emby.Dlna/Didl/Filter.cs

@@ -18,7 +18,7 @@ namespace Emby.Dlna.Didl
         {
             _all = string.Equals(filter, "*", StringComparison.OrdinalIgnoreCase);
 
-            _fields = (filter ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
+            _fields = (filter ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries);
         }
 
         public bool Contains(string field)

+ 1 - 1
Emby.Dlna/Didl/StringWriterWithEncoding.cs

@@ -9,7 +9,7 @@ namespace Emby.Dlna.Didl
 {
     public class StringWriterWithEncoding : StringWriter
     {
-        private readonly Encoding _encoding;
+        private readonly Encoding? _encoding;
 
         public StringWriterWithEncoding()
         {

+ 0 - 1
Emby.Dlna/DlnaConfigurationFactory.cs

@@ -1,4 +1,3 @@
-#nullable enable
 #pragma warning disable CS1591
 
 using System.Collections.Generic;

+ 94 - 111
Emby.Dlna/DlnaManager.cs

@@ -1,5 +1,4 @@
 #pragma warning disable CS1591
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -7,10 +6,12 @@ using System.IO;
 using System.Linq;
 using System.Reflection;
 using System.Text;
+using System.Text.Json;
 using System.Text.RegularExpressions;
 using System.Threading.Tasks;
 using Emby.Dlna.Profiles;
 using Emby.Dlna.Server;
+using Jellyfin.Extensions.Json;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller;
@@ -32,9 +33,9 @@ namespace Emby.Dlna
         private readonly IXmlSerializer _xmlSerializer;
         private readonly IFileSystem _fileSystem;
         private readonly ILogger<DlnaManager> _logger;
-        private readonly IJsonSerializer _jsonSerializer;
         private readonly IServerApplicationHost _appHost;
         private static readonly Assembly _assembly = typeof(DlnaManager).Assembly;
+        private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
 
         private readonly Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>> _profiles = new Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>>(StringComparer.Ordinal);
 
@@ -43,14 +44,12 @@ namespace Emby.Dlna
             IFileSystem fileSystem,
             IApplicationPaths appPaths,
             ILoggerFactory loggerFactory,
-            IJsonSerializer jsonSerializer,
             IServerApplicationHost appHost)
         {
             _xmlSerializer = xmlSerializer;
             _fileSystem = fileSystem;
             _appPaths = appPaths;
             _logger = loggerFactory.CreateLogger<DlnaManager>();
-            _jsonSerializer = jsonSerializer;
             _appHost = appHost;
         }
 
@@ -94,12 +93,14 @@ namespace Emby.Dlna
             }
         }
 
+        /// <inheritdoc />
         public DeviceProfile GetDefaultProfile()
         {
             return new DefaultProfile();
         }
 
-        public DeviceProfile GetProfile(DeviceIdentification deviceInfo)
+        /// <inheritdoc />
+        public DeviceProfile? GetProfile(DeviceIdentification deviceInfo)
         {
             if (deviceInfo == null)
             {
@@ -109,13 +110,13 @@ namespace Emby.Dlna
             var profile = GetProfiles()
                 .FirstOrDefault(i => i.Identification != null && IsMatch(deviceInfo, i.Identification));
 
-            if (profile != null)
+            if (profile == null)
             {
-                _logger.LogDebug("Found matching device profile: {0}", profile.Name);
+                LogUnmatchedProfile(deviceInfo);
             }
             else
             {
-                LogUnmatchedProfile(deviceInfo);
+                _logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name);
             }
 
             return profile;
@@ -126,92 +127,57 @@ namespace Emby.Dlna
             var builder = new StringBuilder();
 
             builder.AppendLine("No matching device profile found. The default will need to be used.");
-            builder.Append("FriendlyName:").AppendLine(profile.FriendlyName);
-            builder.Append("Manufacturer:").AppendLine(profile.Manufacturer);
-            builder.Append("ManufacturerUrl:").AppendLine(profile.ManufacturerUrl);
-            builder.Append("ModelDescription:").AppendLine(profile.ModelDescription);
-            builder.Append("ModelName:").AppendLine(profile.ModelName);
-            builder.Append("ModelNumber:").AppendLine(profile.ModelNumber);
-            builder.Append("ModelUrl:").AppendLine(profile.ModelUrl);
-            builder.Append("SerialNumber:").AppendLine(profile.SerialNumber);
+            builder.Append("FriendlyName: ").AppendLine(profile.FriendlyName);
+            builder.Append("Manufacturer: ").AppendLine(profile.Manufacturer);
+            builder.Append("ManufacturerUrl: ").AppendLine(profile.ManufacturerUrl);
+            builder.Append("ModelDescription: ").AppendLine(profile.ModelDescription);
+            builder.Append("ModelName: ").AppendLine(profile.ModelName);
+            builder.Append("ModelNumber: ").AppendLine(profile.ModelNumber);
+            builder.Append("ModelUrl: ").AppendLine(profile.ModelUrl);
+            builder.Append("SerialNumber: ").AppendLine(profile.SerialNumber);
 
             _logger.LogInformation(builder.ToString());
         }
 
-        private bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo)
+        /// <summary>
+        /// Attempts to match a device with a profile.
+        /// Rules:
+        /// - If the profile field has no value, the field matches irregardless of its contents.
+        /// - the profile field can be an exact match, or a reg exp.
+        /// </summary>
+        /// <param name="deviceInfo">The <see cref="DeviceIdentification"/> of the device.</param>
+        /// <param name="profileInfo">The <see cref="DeviceIdentification"/> of the profile.</param>
+        /// <returns><b>True</b> if they match.</returns>
+        public bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo)
         {
-            if (!string.IsNullOrEmpty(profileInfo.FriendlyName))
-            {
-                if (deviceInfo.FriendlyName == null || !IsRegexOrSubstringMatch(deviceInfo.FriendlyName, profileInfo.FriendlyName))
-                {
-                    return false;
-                }
-            }
-
-            if (!string.IsNullOrEmpty(profileInfo.Manufacturer))
-            {
-                if (deviceInfo.Manufacturer == null || !IsRegexOrSubstringMatch(deviceInfo.Manufacturer, profileInfo.Manufacturer))
-                {
-                    return false;
-                }
-            }
-
-            if (!string.IsNullOrEmpty(profileInfo.ManufacturerUrl))
-            {
-                if (deviceInfo.ManufacturerUrl == null || !IsRegexOrSubstringMatch(deviceInfo.ManufacturerUrl, profileInfo.ManufacturerUrl))
-                {
-                    return false;
-                }
-            }
-
-            if (!string.IsNullOrEmpty(profileInfo.ModelDescription))
-            {
-                if (deviceInfo.ModelDescription == null || !IsRegexOrSubstringMatch(deviceInfo.ModelDescription, profileInfo.ModelDescription))
-                {
-                    return false;
-                }
-            }
-
-            if (!string.IsNullOrEmpty(profileInfo.ModelName))
-            {
-                if (deviceInfo.ModelName == null || !IsRegexOrSubstringMatch(deviceInfo.ModelName, profileInfo.ModelName))
-                {
-                    return false;
-                }
-            }
-
-            if (!string.IsNullOrEmpty(profileInfo.ModelNumber))
-            {
-                if (deviceInfo.ModelNumber == null || !IsRegexOrSubstringMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber))
-                {
-                    return false;
-                }
-            }
+            return IsRegexOrSubstringMatch(deviceInfo.FriendlyName, profileInfo.FriendlyName)
+                && IsRegexOrSubstringMatch(deviceInfo.Manufacturer, profileInfo.Manufacturer)
+                && IsRegexOrSubstringMatch(deviceInfo.ManufacturerUrl, profileInfo.ManufacturerUrl)
+                && IsRegexOrSubstringMatch(deviceInfo.ModelDescription, profileInfo.ModelDescription)
+                && IsRegexOrSubstringMatch(deviceInfo.ModelName, profileInfo.ModelName)
+                && IsRegexOrSubstringMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber)
+                && IsRegexOrSubstringMatch(deviceInfo.ModelUrl, profileInfo.ModelUrl)
+                && IsRegexOrSubstringMatch(deviceInfo.SerialNumber, profileInfo.SerialNumber);
+        }
 
-            if (!string.IsNullOrEmpty(profileInfo.ModelUrl))
+        private bool IsRegexOrSubstringMatch(string input, string pattern)
+        {
+            if (string.IsNullOrEmpty(pattern))
             {
-                if (deviceInfo.ModelUrl == null || !IsRegexOrSubstringMatch(deviceInfo.ModelUrl, profileInfo.ModelUrl))
-                {
-                    return false;
-                }
+                // In profile identification: An empty pattern matches anything.
+                return true;
             }
 
-            if (!string.IsNullOrEmpty(profileInfo.SerialNumber))
+            if (string.IsNullOrEmpty(input))
             {
-                if (deviceInfo.SerialNumber == null || !IsRegexOrSubstringMatch(deviceInfo.SerialNumber, profileInfo.SerialNumber))
-                {
-                    return false;
-                }
+                // The profile contains a value, and the device doesn't.
+                return false;
             }
 
-            return true;
-        }
-
-        private bool IsRegexOrSubstringMatch(string input, string pattern)
-        {
             try
             {
-                return input.Contains(pattern, StringComparison.OrdinalIgnoreCase) || Regex.IsMatch(input, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+                return input.Equals(pattern, StringComparison.OrdinalIgnoreCase)
+                    || Regex.IsMatch(input, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
             }
             catch (ArgumentException ex)
             {
@@ -220,7 +186,8 @@ namespace Emby.Dlna
             }
         }
 
-        public DeviceProfile GetProfile(IHeaderDictionary headers)
+        /// <inheritdoc />
+        public DeviceProfile? GetProfile(IHeaderDictionary headers)
         {
             if (headers == null)
             {
@@ -228,15 +195,13 @@ namespace Emby.Dlna
             }
 
             var profile = GetProfiles().FirstOrDefault(i => i.Identification != null && IsMatch(headers, i.Identification));
-
-            if (profile != null)
+            if (profile == null)
             {
-                _logger.LogDebug("Found matching device profile: {0}", profile.Name);
+                _logger.LogDebug("No matching device profile found. {@Headers}", headers);
             }
             else
             {
-                var headerString = string.Join(", ", headers.Select(i => string.Format(CultureInfo.InvariantCulture, "{0}={1}", i.Key, i.Value)));
-                _logger.LogDebug("No matching device profile found. {0}", headerString);
+                _logger.LogDebug("Found matching device profile: {0}", profile.Name);
             }
 
             return profile;
@@ -286,19 +251,19 @@ namespace Emby.Dlna
                 return xmlFies
                     .Select(i => ParseProfileFile(i, type))
                     .Where(i => i != null)
-                    .ToList();
+                    .ToList()!; // We just filtered out all the nulls
             }
             catch (IOException)
             {
-                return new List<DeviceProfile>();
+                return Array.Empty<DeviceProfile>();
             }
         }
 
-        private DeviceProfile ParseProfileFile(string path, DeviceProfileType type)
+        private DeviceProfile? ParseProfileFile(string path, DeviceProfileType type)
         {
             lock (_profiles)
             {
-                if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile> profileTuple))
+                if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile>? profileTuple))
                 {
                     return profileTuple.Item2;
                 }
@@ -326,14 +291,20 @@ namespace Emby.Dlna
             }
         }
 
-        public DeviceProfile GetProfile(string id)
+        /// <inheritdoc />
+        public DeviceProfile? GetProfile(string id)
         {
             if (string.IsNullOrEmpty(id))
             {
                 throw new ArgumentNullException(nameof(id));
             }
 
-            var info = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, id, StringComparison.OrdinalIgnoreCase));
+            var info = GetProfileInfosInternal().FirstOrDefault(i => string.Equals(i.Info.Id, id, StringComparison.OrdinalIgnoreCase));
+
+            if (info == null)
+            {
+                return null;
+            }
 
             return ParseProfileFile(info.Path, info.Info.Type);
         }
@@ -350,6 +321,7 @@ namespace Emby.Dlna
             }
         }
 
+        /// <inheritdoc />
         public IEnumerable<DeviceProfileInfo> GetProfileInfos()
         {
             return GetProfileInfosInternal().Select(i => i.Info);
@@ -357,17 +329,14 @@ namespace Emby.Dlna
 
         private InternalProfileInfo GetInternalProfileInfo(FileSystemMetadata file, DeviceProfileType type)
         {
-            return new InternalProfileInfo
-            {
-                Path = file.FullName,
-
-                Info = new DeviceProfileInfo
+            return new InternalProfileInfo(
+                new DeviceProfileInfo
                 {
                     Id = file.FullName.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture),
                     Name = _fileSystem.GetFileNameWithoutExtension(file),
                     Type = type
-                }
-            };
+                },
+                file.FullName);
         }
 
         private async Task ExtractSystemProfilesAsync()
@@ -383,11 +352,12 @@ namespace Emby.Dlna
                     continue;
                 }
 
-                var filename = Path.GetFileName(name).Substring(namespaceName.Length);
-
-                var path = Path.Combine(systemProfilesPath, filename);
+                var path = Path.Join(
+                    systemProfilesPath,
+                    Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length));
 
-                using (var stream = _assembly.GetManifestResourceStream(name))
+                // The stream should exist as we just got its name from GetManifestResourceNames
+                using (var stream = _assembly.GetManifestResourceStream(name)!)
                 {
                     var fileInfo = _fileSystem.GetFileInfo(path);
 
@@ -395,7 +365,8 @@ namespace Emby.Dlna
                     {
                         Directory.CreateDirectory(systemProfilesPath);
 
-                        using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
+                        // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 .
+                        using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
                         {
                             await stream.CopyToAsync(fileStream).ConfigureAwait(false);
                         }
@@ -407,6 +378,7 @@ namespace Emby.Dlna
             Directory.CreateDirectory(UserProfilesPath);
         }
 
+        /// <inheritdoc />
         public void DeleteProfile(string id)
         {
             var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase));
@@ -424,6 +396,7 @@ namespace Emby.Dlna
             }
         }
 
+        /// <inheritdoc />
         public void CreateProfile(DeviceProfile profile)
         {
             profile = ReserializeProfile(profile);
@@ -439,6 +412,7 @@ namespace Emby.Dlna
             SaveProfile(profile, path, DeviceProfileType.User);
         }
 
+        /// <inheritdoc />
         public void UpdateProfile(DeviceProfile profile)
         {
             profile = ReserializeProfile(profile);
@@ -484,10 +458,10 @@ namespace Emby.Dlna
 
         /// <summary>
         /// Recreates the object using serialization, to ensure it's not a subclass.
-        /// If it's a subclass it may not serlialize properly to xml (different root element tag name).
+        /// If it's a subclass it may not serialize properly to xml (different root element tag name).
         /// </summary>
         /// <param name="profile">The device profile.</param>
-        /// <returns>The reserialized device profile.</returns>
+        /// <returns>The re-serialized device profile.</returns>
         private DeviceProfile ReserializeProfile(DeviceProfile profile)
         {
             if (profile.GetType() == typeof(DeviceProfile))
@@ -495,11 +469,13 @@ namespace Emby.Dlna
                 return profile;
             }
 
-            var json = _jsonSerializer.SerializeToString(profile);
+            var json = JsonSerializer.Serialize(profile, _jsonOptions);
 
-            return _jsonSerializer.DeserializeFromString<DeviceProfile>(json);
+            // Output can't be null if the input isn't null
+            return JsonSerializer.Deserialize<DeviceProfile>(json, _jsonOptions)!;
         }
 
+        /// <inheritdoc />
         public string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress)
         {
             var profile = GetDefaultProfile();
@@ -509,6 +485,7 @@ namespace Emby.Dlna
             return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml();
         }
 
+        /// <inheritdoc />
         public ImageStream GetIcon(string filename)
         {
             var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
@@ -526,9 +503,15 @@ namespace Emby.Dlna
 
         private class InternalProfileInfo
         {
-            internal DeviceProfileInfo Info { get; set; }
+            internal InternalProfileInfo(DeviceProfileInfo info, string path)
+            {
+                Info = info;
+                Path = path;
+            }
+
+            internal DeviceProfileInfo Info { get; }
 
-            internal string Path { get; set; }
+            internal string Path { get; }
         }
     }
 
@@ -553,7 +536,7 @@ namespace Emby.Dlna
 
         private void DumpProfiles()
         {
-            DeviceProfile[] list = new []
+            DeviceProfile[] list = new[]
             {
                 new SamsungSmartTvProfile(),
                 new XboxOneProfile(),

+ 3 - 10
Emby.Dlna/Emby.Dlna.csproj

@@ -17,24 +17,19 @@
   </ItemGroup>
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+    <AnalysisMode>AllDisabledByDefault</AnalysisMode>
   </PropertyGroup>
 
   <!-- Code Analyzers-->
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
     <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
     <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
   </ItemGroup>
 
-  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
-  </PropertyGroup>
-
   <ItemGroup>
     <EmbeddedResource Include="Images\logo120.jpg" />
     <EmbeddedResource Include="Images\logo120.png" />
@@ -78,9 +73,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
-    <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="2.2.0" />
-    <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.6" />
+    <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
   </ItemGroup>
 
 </Project>

+ 3 - 1
Emby.Dlna/EventSubscriptionResponse.cs

@@ -6,8 +6,10 @@ namespace Emby.Dlna
 {
     public class EventSubscriptionResponse
     {
-        public EventSubscriptionResponse()
+        public EventSubscriptionResponse(string content, string contentType)
         {
+            Content = content;
+            ContentType = contentType;
             Headers = new Dictionary<string, string>();
         }
 

+ 9 - 18
Emby.Dlna/Eventing/DlnaEventManager.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;
@@ -49,11 +51,7 @@ namespace Emby.Dlna.Eventing
                 return GetEventSubscriptionResponse(subscriptionId, requestedTimeoutString, timeoutSeconds);
             }
 
-            return new EventSubscriptionResponse
-            {
-                Content = string.Empty,
-                ContentType = "text/plain"
-            };
+            return new EventSubscriptionResponse(string.Empty, "text/plain");
         }
 
         public EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl)
@@ -72,7 +70,8 @@ namespace Emby.Dlna.Eventing
                 Id = id,
                 CallbackUrl = callbackUrl,
                 SubscriptionTime = DateTime.UtcNow,
-                TimeoutSeconds = timeout
+                TimeoutSeconds = timeout,
+                NotificationType = notificationType
             });
 
             return GetEventSubscriptionResponse(id, requestedTimeoutString, timeout);
@@ -83,7 +82,7 @@ namespace Emby.Dlna.Eventing
             if (!string.IsNullOrEmpty(header))
             {
                 // Starts with SECOND-
-                header = header.Split('-').Last();
+                header = header.Split('-')[^1];
 
                 if (int.TryParse(header, NumberStyles.Integer, _usCulture, out var val))
                 {
@@ -100,20 +99,12 @@ namespace Emby.Dlna.Eventing
 
             _subscriptions.TryRemove(subscriptionId, out _);
 
-            return new EventSubscriptionResponse
-            {
-                Content = string.Empty,
-                ContentType = "text/plain"
-            };
+            return new EventSubscriptionResponse(string.Empty, "text/plain");
         }
 
         private EventSubscriptionResponse GetEventSubscriptionResponse(string subscriptionId, string requestedTimeoutString, int timeoutSeconds)
         {
-            var response = new EventSubscriptionResponse
-            {
-                Content = string.Empty,
-                ContentType = "text/plain"
-            };
+            var response = new EventSubscriptionResponse(string.Empty, "text/plain");
 
             response.Headers["SID"] = subscriptionId;
             response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(_usCulture)) : requestedTimeoutString;
@@ -168,7 +159,7 @@ namespace Emby.Dlna.Eventing
 
             builder.Append("</e:propertyset>");
 
-            using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"),  subscription.CallbackUrl);
+            using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"), subscription.CallbackUrl);
             options.Content = new StringContent(builder.ToString(), Encoding.UTF8, MediaTypeNames.Text.Xml);
             options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType);
             options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange");

+ 2 - 0
Emby.Dlna/Eventing/EventSubscription.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;

+ 70 - 24
Emby.Dlna/Main/DlnaEntryPoint.cs

@@ -1,13 +1,17 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;
 using System.Globalization;
+using System.Linq;
 using System.Net.Http;
 using System.Net.Sockets;
-using System.Threading;
 using System.Threading.Tasks;
 using Emby.Dlna.PlayTo;
 using Emby.Dlna.Ssdp;
+using Jellyfin.Networking.Configuration;
+using Jellyfin.Networking.Manager;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
@@ -23,11 +27,9 @@ using MediaBrowser.Controller.TV;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Net;
-using MediaBrowser.Model.System;
 using Microsoft.Extensions.Logging;
 using Rssdp;
 using Rssdp.Infrastructure;
-using OperatingSystem = MediaBrowser.Common.System.OperatingSystem;
 
 namespace Emby.Dlna.Main
 {
@@ -50,6 +52,8 @@ namespace Emby.Dlna.Main
         private readonly ISocketFactory _socketFactory;
         private readonly INetworkManager _networkManager;
         private readonly object _syncLock = new object();
+        private readonly NetworkConfiguration _netConfig;
+        private readonly bool _disabled;
 
         private PlayToManager _manager;
         private SsdpDevicePublisher _publisher;
@@ -120,10 +124,23 @@ namespace Emby.Dlna.Main
                 httpClientFactory,
                 config);
             Current = this;
+
+            _netConfig = config.GetConfiguration<NetworkConfiguration>("network");
+            _disabled = appHost.ListenWithHttps && _netConfig.RequireHttps;
+
+            if (_disabled && _config.GetDlnaConfiguration().EnableServer)
+            {
+                _logger.LogError("The DLNA specification does not support HTTPS.");
+            }
         }
 
         public static DlnaEntryPoint Current { get; private set; }
 
+        /// <summary>
+        /// Gets a value indicating whether the dlna server is enabled.
+        /// </summary>
+        public static bool Enabled { get; private set; }
+
         public IContentDirectory ContentDirectory { get; private set; }
 
         public IConnectionManager ConnectionManager { get; private set; }
@@ -134,28 +151,35 @@ namespace Emby.Dlna.Main
         {
             await ((DlnaManager)_dlnaManager).InitProfilesAsync().ConfigureAwait(false);
 
-            await ReloadComponents().ConfigureAwait(false);
+            if (_disabled)
+            {
+                // No use starting as dlna won't work, as we're running purely on HTTPS.
+                return;
+            }
+
+            ReloadComponents();
 
             _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
         }
 
-        private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
+        private void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
         {
             if (string.Equals(e.Key, "dlna", StringComparison.OrdinalIgnoreCase))
             {
-                await ReloadComponents().ConfigureAwait(false);
+                ReloadComponents();
             }
         }
 
-        private async Task ReloadComponents()
+        private void ReloadComponents()
         {
             var options = _config.GetDlnaConfiguration();
+            Enabled = options.EnableServer;
 
             StartSsdpHandler();
 
             if (options.EnableServer)
             {
-                await StartDevicePublisher(options).ConfigureAwait(false);
+                StartDevicePublisher(options);
             }
             else
             {
@@ -178,8 +202,8 @@ namespace Emby.Dlna.Main
             {
                 if (_communicationsServer == null)
                 {
-                    var enableMultiSocketBinding = OperatingSystem.Id == OperatingSystemId.Windows ||
-                                                   OperatingSystem.Id == OperatingSystemId.Linux;
+                    var enableMultiSocketBinding = OperatingSystem.IsWindows() ||
+                                                   OperatingSystem.IsLinux();
 
                     _communicationsServer = new SsdpCommunicationsServer(_socketFactory, _networkManager, _logger, enableMultiSocketBinding)
                     {
@@ -204,7 +228,10 @@ namespace Emby.Dlna.Main
         {
             try
             {
-                ((DeviceDiscovery)_deviceDiscovery).Start(communicationsServer);
+                if (communicationsServer != null)
+                {
+                    ((DeviceDiscovery)_deviceDiscovery).Start(communicationsServer);
+                }
             }
             catch (Exception ex)
             {
@@ -225,7 +252,7 @@ namespace Emby.Dlna.Main
             }
         }
 
-        public async Task StartDevicePublisher(Configuration.DlnaOptions options)
+        public void StartDevicePublisher(Configuration.DlnaOptions options)
         {
             if (!options.BlastAliveMessages)
             {
@@ -239,13 +266,18 @@ namespace Emby.Dlna.Main
 
             try
             {
-                _publisher = new SsdpDevicePublisher(_communicationsServer, _networkManager, OperatingSystem.Name, Environment.OSVersion.VersionString, _config.GetDlnaConfiguration().SendOnlyMatchedHost)
+                _publisher = new SsdpDevicePublisher(
+                    _communicationsServer,
+                    _networkManager,
+                    MediaBrowser.Common.System.OperatingSystem.Name,
+                    Environment.OSVersion.VersionString,
+                    _config.GetDlnaConfiguration().SendOnlyMatchedHost)
                 {
                     LogFunction = LogMessage,
                     SupportPnpRootDevice = false
                 };
 
-                await RegisterServerEndpoints().ConfigureAwait(false);
+                RegisterServerEndpoints();
 
                 _publisher.StartBroadcastingAliveMessages(TimeSpan.FromSeconds(options.BlastAliveMessageIntervalSeconds));
             }
@@ -255,13 +287,22 @@ namespace Emby.Dlna.Main
             }
         }
 
-        private async Task RegisterServerEndpoints()
+        private void RegisterServerEndpoints()
         {
-            var addresses = await _appHost.GetLocalIpAddresses(CancellationToken.None).ConfigureAwait(false);
-
             var udn = CreateUuid(_appHost.SystemId);
+            var descriptorUri = "/dlna/" + udn + "/description.xml";
 
-            foreach (var address in addresses)
+            var bindAddresses = NetworkManager.CreateCollection(
+                _networkManager.GetInternalBindAddresses()
+                .Where(i => i.AddressFamily == AddressFamily.InterNetwork || (i.AddressFamily == AddressFamily.InterNetworkV6 && i.Address.ScopeId != 0)));
+
+            if (bindAddresses.Count == 0)
+            {
+                // No interfaces returned, so use loopback.
+                bindAddresses = _networkManager.GetLoopbacks();
+            }
+
+            foreach (IPNetAddress address in bindAddresses)
             {
                 if (address.AddressFamily == AddressFamily.InterNetworkV6)
                 {
@@ -270,7 +311,7 @@ namespace Emby.Dlna.Main
                 }
 
                 // Limit to LAN addresses only
-                if (!_networkManager.IsAddressInSubnets(address, true, true))
+                if (!_networkManager.IsInLocalNetwork(address))
                 {
                     continue;
                 }
@@ -279,15 +320,20 @@ namespace Emby.Dlna.Main
 
                 _logger.LogInformation("Registering publisher for {0} on {1}", fullService, address);
 
-                var descriptorUri = "/dlna/" + udn + "/description.xml";
-                var uri = new Uri(_appHost.GetLocalApiUrl(address) + descriptorUri);
+                var uri = new UriBuilder(_appHost.GetSmartApiUrl(address.Address) + descriptorUri);
+                if (!string.IsNullOrEmpty(_appHost.PublishedServerUrl))
+                {
+                    // DLNA will only work over http, so we must reset to http:// : {port}.
+                    uri.Scheme = "http";
+                    uri.Port = _netConfig.HttpServerPortNumber;
+                }
 
                 var device = new SsdpRootDevice
                 {
                     CacheLifetime = TimeSpan.FromSeconds(1800), // How long SSDP clients can cache this info.
-                    Location = uri, // Must point to the URL that serves your devices UPnP description document.
-                    Address = address,
-                    SubnetMask = _networkManager.GetLocalIpSubnetMask(address),
+                    Location = uri.Uri, // Must point to the URL that serves your devices UPnP description document.
+                    Address = address.Address,
+                    PrefixLength = address.PrefixLength,
                     FriendlyName = "Jellyfin",
                     Manufacturer = "Jellyfin",
                     ModelName = "Jellyfin Server",

+ 1 - 1
Emby.Dlna/MediaReceiverRegistrar/ControlHandler.cs

@@ -24,7 +24,7 @@ namespace Emby.Dlna.MediaReceiverRegistrar
         }
 
         /// <inheritdoc />
-        protected override void WriteResult(string methodName, IDictionary<string, string> methodParams, XmlWriter xmlWriter)
+        protected override void WriteResult(string methodName, IReadOnlyDictionary<string, string> methodParams, XmlWriter xmlWriter)
         {
             if (string.Equals(methodName, "IsAuthorized", StringComparison.OrdinalIgnoreCase))
             {

+ 0 - 1
Emby.Dlna/MediaReceiverRegistrar/MediaReceiverRegistrarXmlBuilder.cs

@@ -1,7 +1,6 @@
 using System.Collections.Generic;
 using Emby.Dlna.Common;
 using Emby.Dlna.Service;
-using MediaBrowser.Model.Dlna;
 
 namespace Emby.Dlna.MediaReceiverRegistrar
 {

+ 0 - 1
Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs

@@ -1,6 +1,5 @@
 using System.Collections.Generic;
 using Emby.Dlna.Common;
-using MediaBrowser.Model.Dlna;
 
 namespace Emby.Dlna.MediaReceiverRegistrar
 {

+ 127 - 42
Emby.Dlna/PlayTo/Device.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;
@@ -12,8 +14,6 @@ using System.Xml;
 using System.Xml.Linq;
 using Emby.Dlna.Common;
 using Emby.Dlna.Ssdp;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Configuration;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Dlna.PlayTo
@@ -221,7 +221,7 @@ namespace Emby.Dlna.PlayTo
         {
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
 
-            var command = rendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "SetMute");
+            var command = rendererCommands?.ServiceActions.FirstOrDefault(c => c.Name == "SetMute");
             if (command == null)
             {
                 return false;
@@ -237,7 +237,13 @@ namespace Emby.Dlna.PlayTo
             _logger.LogDebug("Setting mute");
             var value = mute ? 1 : 0;
 
-            await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, rendererCommands.BuildPost(command, service.ServiceType, value))
+            await new SsdpHttpClient(_httpClientFactory)
+                .SendCommandAsync(
+                    Properties.BaseUrl,
+                    service,
+                    command.Name,
+                    rendererCommands.BuildPost(command, service.ServiceType, value),
+                    cancellationToken: cancellationToken)
                 .ConfigureAwait(false);
 
             IsMuted = mute;
@@ -255,7 +261,7 @@ namespace Emby.Dlna.PlayTo
         {
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
 
-            var command = rendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "SetVolume");
+            var command = rendererCommands?.ServiceActions.FirstOrDefault(c => c.Name == "SetVolume");
             if (command == null)
             {
                 return;
@@ -272,7 +278,13 @@ namespace Emby.Dlna.PlayTo
             // Remote control will perform better
             Volume = value;
 
-            await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, rendererCommands.BuildPost(command, service.ServiceType, value))
+            await new SsdpHttpClient(_httpClientFactory)
+                .SendCommandAsync(
+                    Properties.BaseUrl,
+                    service,
+                    command.Name,
+                    rendererCommands.BuildPost(command, service.ServiceType, value),
+                    cancellationToken: cancellationToken)
                 .ConfigureAwait(false);
         }
 
@@ -280,7 +292,7 @@ namespace Emby.Dlna.PlayTo
         {
             var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
 
-            var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "Seek");
+            var command = avCommands?.ServiceActions.FirstOrDefault(c => c.Name == "Seek");
             if (command == null)
             {
                 return;
@@ -293,7 +305,13 @@ namespace Emby.Dlna.PlayTo
                 throw new InvalidOperationException("Unable to find service");
             }
 
-            await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, avCommands.BuildPost(command, service.ServiceType, string.Format(CultureInfo.InvariantCulture, "{0:hh}:{0:mm}:{0:ss}", value), "REL_TIME"))
+            await new SsdpHttpClient(_httpClientFactory)
+                .SendCommandAsync(
+                    Properties.BaseUrl,
+                    service,
+                    command.Name,
+                    avCommands.BuildPost(command, service.ServiceType, string.Format(CultureInfo.InvariantCulture, "{0:hh}:{0:mm}:{0:ss}", value), "REL_TIME"),
+                    cancellationToken: cancellationToken)
                 .ConfigureAwait(false);
 
             RestartTimer(true);
@@ -307,7 +325,7 @@ namespace Emby.Dlna.PlayTo
 
             _logger.LogDebug("{0} - SetAvTransport Uri: {1} DlnaHeaders: {2}", Properties.Name, url, header);
 
-            var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "SetAVTransportURI");
+            var command = avCommands?.ServiceActions.FirstOrDefault(c => c.Name == "SetAVTransportURI");
             if (command == null)
             {
                 return;
@@ -327,14 +345,21 @@ namespace Emby.Dlna.PlayTo
             }
 
             var post = avCommands.BuildPost(command, service.ServiceType, url, dictionary);
-            await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, post, header: header)
+            await new SsdpHttpClient(_httpClientFactory)
+                .SendCommandAsync(
+                    Properties.BaseUrl,
+                    service,
+                    command.Name,
+                    post,
+                    header: header,
+                    cancellationToken: cancellationToken)
                 .ConfigureAwait(false);
 
-            await Task.Delay(50).ConfigureAwait(false);
+            await Task.Delay(50, cancellationToken).ConfigureAwait(false);
 
             try
             {
-                await SetPlay(avCommands, CancellationToken.None).ConfigureAwait(false);
+                await SetPlay(avCommands, cancellationToken).ConfigureAwait(false);
             }
             catch
             {
@@ -345,7 +370,43 @@ namespace Emby.Dlna.PlayTo
             RestartTimer(true);
         }
 
-        private string CreateDidlMeta(string value)
+        /*
+         * SetNextAvTransport is used to specify to the DLNA device what is the next track to play.
+         * Without that information, the next track command on the device does not work.
+         */
+        public async Task SetNextAvTransport(string url, string header, string metaData, CancellationToken cancellationToken = default)
+        {
+            var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
+
+            url = url.Replace("&", "&amp;", StringComparison.Ordinal);
+
+            _logger.LogDebug("{PropertyName} - SetNextAvTransport Uri: {Url} DlnaHeaders: {Header}", Properties.Name, url, header);
+
+            var command = avCommands.ServiceActions.FirstOrDefault(c => string.Equals(c.Name, "SetNextAVTransportURI", StringComparison.OrdinalIgnoreCase));
+            if (command == null)
+            {
+                return;
+            }
+
+            var dictionary = new Dictionary<string, string>
+            {
+                { "NextURI", url },
+                { "NextURIMetaData", CreateDidlMeta(metaData) }
+            };
+
+            var service = GetAvTransportService();
+
+            if (service == null)
+            {
+                throw new InvalidOperationException("Unable to find service");
+            }
+
+            var post = avCommands.BuildPost(command, service.ServiceType, url, dictionary);
+            await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, post, header: header, cancellationToken)
+                .ConfigureAwait(false);
+        }
+
+        private static string CreateDidlMeta(string value)
         {
             if (string.IsNullOrEmpty(value))
             {
@@ -380,6 +441,10 @@ namespace Emby.Dlna.PlayTo
         public async Task SetPlay(CancellationToken cancellationToken)
         {
             var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
+            if (avCommands == null)
+            {
+                return;
+            }
 
             await SetPlay(avCommands, cancellationToken).ConfigureAwait(false);
 
@@ -390,7 +455,7 @@ namespace Emby.Dlna.PlayTo
         {
             var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
 
-            var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "Stop");
+            var command = avCommands?.ServiceActions.FirstOrDefault(c => c.Name == "Stop");
             if (command == null)
             {
                 return;
@@ -398,7 +463,13 @@ namespace Emby.Dlna.PlayTo
 
             var service = GetAvTransportService();
 
-            await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, avCommands.BuildPost(command, service.ServiceType, 1))
+            await new SsdpHttpClient(_httpClientFactory)
+                .SendCommandAsync(
+                    Properties.BaseUrl,
+                    service,
+                    command.Name,
+                    avCommands.BuildPost(command, service.ServiceType, 1),
+                    cancellationToken: cancellationToken)
                 .ConfigureAwait(false);
 
             RestartTimer(true);
@@ -408,7 +479,7 @@ namespace Emby.Dlna.PlayTo
         {
             var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
 
-            var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "Pause");
+            var command = avCommands?.ServiceActions.FirstOrDefault(c => c.Name == "Pause");
             if (command == null)
             {
                 return;
@@ -416,7 +487,13 @@ namespace Emby.Dlna.PlayTo
 
             var service = GetAvTransportService();
 
-            await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, avCommands.BuildPost(command, service.ServiceType, 1))
+            await new SsdpHttpClient(_httpClientFactory)
+                .SendCommandAsync(
+                    Properties.BaseUrl,
+                    service,
+                    command.Name,
+                    avCommands.BuildPost(command, service.ServiceType, 1),
+                    cancellationToken: cancellationToken)
                 .ConfigureAwait(false);
 
             TransportState = TransportState.Paused;
@@ -480,7 +557,7 @@ namespace Emby.Dlna.PlayTo
                         return;
                     }
 
-                    // If we're not playing anything make sure we don't get data more often than neccessry to keep the Session alive
+                    // If we're not playing anything make sure we don't get data more often than necessary to keep the Session alive
                     if (transportState.Value == TransportState.Stopped)
                     {
                         RestartTimerInactive();
@@ -530,7 +607,7 @@ namespace Emby.Dlna.PlayTo
 
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
 
-            var command = rendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetVolume");
+            var command = rendererCommands?.ServiceActions.FirstOrDefault(c => c.Name == "GetVolume");
             if (command == null)
             {
                 return;
@@ -580,7 +657,7 @@ namespace Emby.Dlna.PlayTo
 
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
 
-            var command = rendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetMute");
+            var command = rendererCommands?.ServiceActions.FirstOrDefault(c => c.Name == "GetMute");
             if (command == null)
             {
                 return;
@@ -667,6 +744,10 @@ namespace Emby.Dlna.PlayTo
             }
 
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
+            if (rendererCommands == null)
+            {
+                return null;
+            }
 
             var result = await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(
                 Properties.BaseUrl,
@@ -735,6 +816,11 @@ namespace Emby.Dlna.PlayTo
 
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
 
+            if (rendererCommands == null)
+            {
+                return (false, null);
+            }
+
             var result = await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(
                 Properties.BaseUrl,
                 service,
@@ -775,7 +861,7 @@ namespace Emby.Dlna.PlayTo
 
             if (track == null)
             {
-                // If track is null, some vendors do this, use GetMediaInfo instead
+                // If track is null, some vendors do this, use GetMediaInfo instead.
                 return (true, null);
             }
 
@@ -812,7 +898,7 @@ namespace Emby.Dlna.PlayTo
 
         private XElement ParseResponse(string xml)
         {
-            // Handle different variations sent back by devices
+            // Handle different variations sent back by devices.
             try
             {
                 return XElement.Parse(xml);
@@ -821,7 +907,7 @@ namespace Emby.Dlna.PlayTo
             {
             }
 
-            // first try to add a root node with a dlna namesapce
+            // first try to add a root node with a dlna namespace.
             try
             {
                 return XElement.Parse("<data xmlns:dlna=\"urn:schemas-dlna-org:device-1-0\">" + xml + "</data>")
@@ -916,6 +1002,10 @@ namespace Emby.Dlna.PlayTo
             var httpClient = new SsdpHttpClient(_httpClientFactory);
 
             var document = await httpClient.GetDataAsync(url, cancellationToken).ConfigureAwait(false);
+            if (document == null)
+            {
+                return null;
+            }
 
             AvCommands = TransportCommands.Create(document);
             return AvCommands;
@@ -944,6 +1034,10 @@ namespace Emby.Dlna.PlayTo
             var httpClient = new SsdpHttpClient(_httpClientFactory);
             _logger.LogDebug("Dlna Device.GetRenderingProtocolAsync");
             var document = await httpClient.GetDataAsync(url, cancellationToken).ConfigureAwait(false);
+            if (document == null)
+            {
+                return null;
+            }
 
             RendererCommands = TransportCommands.Create(document);
             return RendererCommands;
@@ -962,7 +1056,7 @@ namespace Emby.Dlna.PlayTo
                 url = "/dmr/" + url;
             }
 
-            if (!url.StartsWith("/", StringComparison.Ordinal))
+            if (!url.StartsWith('/'))
             {
                 url = "/" + url;
             }
@@ -975,6 +1069,10 @@ namespace Emby.Dlna.PlayTo
             var ssdpHttpClient = new SsdpHttpClient(httpClientFactory);
 
             var document = await ssdpHttpClient.GetDataAsync(url.ToString(), cancellationToken).ConfigureAwait(false);
+            if (document == null)
+            {
+                return null;
+            }
 
             var friendlyNames = new List<string>();
 
@@ -992,7 +1090,7 @@ namespace Emby.Dlna.PlayTo
 
             var deviceProperties = new DeviceInfo()
             {
-                Name = string.Join(" ", friendlyNames),
+                Name = string.Join(' ', friendlyNames),
                 BaseUrl = string.Format(CultureInfo.InvariantCulture, "http://{0}:{1}", url.Host, url.Port)
             };
 
@@ -1162,10 +1260,7 @@ namespace Emby.Dlna.PlayTo
                 return;
             }
 
-            PlaybackStart?.Invoke(this, new PlaybackStartEventArgs
-            {
-                MediaInfo = mediaInfo
-            });
+            PlaybackStart?.Invoke(this, new PlaybackStartEventArgs(mediaInfo));
         }
 
         private void OnPlaybackProgress(UBaseObject mediaInfo)
@@ -1175,27 +1270,17 @@ namespace Emby.Dlna.PlayTo
                 return;
             }
 
-            PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs
-            {
-                MediaInfo = mediaInfo
-            });
+            PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs(mediaInfo));
         }
 
         private void OnPlaybackStop(UBaseObject mediaInfo)
         {
-            PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs
-            {
-                MediaInfo = mediaInfo
-            });
+            PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs(mediaInfo));
         }
 
         private void OnMediaChanged(UBaseObject old, UBaseObject newMedia)
         {
-            MediaChanged?.Invoke(this, new MediaChangedEventArgs
-            {
-                OldMediaInfo = old,
-                NewMediaInfo = newMedia
-            });
+            MediaChanged?.Invoke(this, new MediaChangedEventArgs(old, newMedia));
         }
 
         /// <inheritdoc />

+ 2 - 0
Emby.Dlna/PlayTo/DeviceInfo.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System.Collections.Generic;

+ 7 - 1
Emby.Dlna/PlayTo/MediaChangedEventArgs.cs

@@ -1,4 +1,4 @@
-#pragma warning disable CS1591
+#pragma warning disable CS1591
 
 using System;
 
@@ -6,6 +6,12 @@ namespace Emby.Dlna.PlayTo
 {
     public class MediaChangedEventArgs : EventArgs
     {
+        public MediaChangedEventArgs(UBaseObject oldMediaInfo, UBaseObject newMediaInfo)
+        {
+            OldMediaInfo = oldMediaInfo;
+            NewMediaInfo = newMediaInfo;
+        }
+
         public UBaseObject OldMediaInfo { get; set; }
 
         public UBaseObject NewMediaInfo { get; set; }

+ 79 - 39
Emby.Dlna/PlayTo/PlayToController.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;
@@ -9,7 +11,6 @@ using System.Threading.Tasks;
 using Emby.Dlna.Didl;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Events;
-using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Entities;
@@ -41,7 +42,6 @@ namespace Emby.Dlna.PlayTo
         private readonly IUserDataManager _userDataManager;
         private readonly ILocalizationManager _localization;
         private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IConfigurationManager _config;
         private readonly IMediaEncoder _mediaEncoder;
 
         private readonly IDeviceDiscovery _deviceDiscovery;
@@ -68,7 +68,6 @@ namespace Emby.Dlna.PlayTo
             IUserDataManager userDataManager,
             ILocalizationManager localization,
             IMediaSourceManager mediaSourceManager,
-            IConfigurationManager config,
             IMediaEncoder mediaEncoder)
         {
             _session = session;
@@ -84,7 +83,6 @@ namespace Emby.Dlna.PlayTo
             _userDataManager = userDataManager;
             _localization = localization;
             _mediaSourceManager = mediaSourceManager;
-            _config = config;
             _mediaEncoder = mediaEncoder;
         }
 
@@ -106,6 +104,22 @@ namespace Emby.Dlna.PlayTo
             _deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft;
         }
 
+        /*
+         * Send a message to the DLNA device to notify what is the next track in the playlist.
+         */
+        private async Task SendNextTrackMessage(int currentPlayListItemIndex, CancellationToken cancellationToken)
+        {
+            if (currentPlayListItemIndex >= 0 && currentPlayListItemIndex < _playlist.Count - 1)
+            {
+                // The current playing item is indeed in the play list and we are not yet at the end of the playlist.
+                var nextItemIndex = currentPlayListItemIndex + 1;
+                var nextItem = _playlist[nextItemIndex];
+
+                // Send the SetNextAvTransport message.
+                await _device.SetNextAvTransport(nextItem.StreamUrl, GetDlnaHeaders(nextItem), nextItem.Didl, cancellationToken).ConfigureAwait(false);
+            }
+        }
+
         private void OnDeviceUnavailable()
         {
             try
@@ -136,7 +150,7 @@ namespace Emby.Dlna.PlayTo
 
         private async void OnDeviceMediaChanged(object sender, MediaChangedEventArgs e)
         {
-            if (_disposed)
+            if (_disposed || string.IsNullOrEmpty(e.OldMediaInfo.Url))
             {
                 return;
             }
@@ -160,6 +174,15 @@ namespace Emby.Dlna.PlayTo
                 var newItemProgress = GetProgressInfo(streamInfo);
 
                 await _sessionManager.OnPlaybackStart(newItemProgress).ConfigureAwait(false);
+
+                // Send a message to the DLNA device to notify what is the next track in the playlist.
+                var currentItemIndex = _playlist.FindIndex(item => item.StreamInfo.ItemId == streamInfo.ItemId);
+                if (currentItemIndex >= 0)
+                {
+                    _currentPlaylistIndex = currentItemIndex;
+                }
+
+                await SendNextTrackMessage(currentItemIndex, CancellationToken.None);
             }
             catch (Exception ex)
             {
@@ -326,7 +349,7 @@ namespace Emby.Dlna.PlayTo
 
         public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken)
         {
-            _logger.LogDebug("{0} - Received PlayRequest: {1}", this._session.DeviceName, command.PlayCommand);
+            _logger.LogDebug("{0} - Received PlayRequest: {1}", _session.DeviceName, command.PlayCommand);
 
             var user = command.ControllingUserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(command.ControllingUserId);
 
@@ -337,25 +360,26 @@ namespace Emby.Dlna.PlayTo
             }
 
             var startIndex = command.StartIndex ?? 0;
+            int len = items.Count - startIndex;
             if (startIndex > 0)
             {
-                items = items.Skip(startIndex).ToList();
+                items = items.GetRange(startIndex, len);
             }
 
-            var playlist = new List<PlaylistItem>();
-            var isFirst = true;
+            var playlist = new PlaylistItem[len];
+
+            // Not nullable enabled - so this is required.
+            playlist[0] = CreatePlaylistItem(
+                items[0],
+                user,
+                command.StartPositionTicks ?? 0,
+                command.MediaSourceId ?? string.Empty,
+                command.AudioStreamIndex,
+                command.SubtitleStreamIndex);
 
-            foreach (var item in items)
+            for (int i = 1; i < len; i++)
             {
-                if (isFirst && command.StartPositionTicks.HasValue)
-                {
-                    playlist.Add(CreatePlaylistItem(item, user, command.StartPositionTicks.Value, command.MediaSourceId, command.AudioStreamIndex, command.SubtitleStreamIndex));
-                    isFirst = false;
-                }
-                else
-                {
-                    playlist.Add(CreatePlaylistItem(item, user, 0, null, null, null));
-                }
+                playlist[i] = CreatePlaylistItem(items[i], user, 0, string.Empty, null, null);
             }
 
             _logger.LogDebug("{0} - Playlist created", _session.DeviceName);
@@ -428,6 +452,11 @@ namespace Emby.Dlna.PlayTo
                     var newItem = CreatePlaylistItem(info.Item, user, newPosition, info.MediaSourceId, info.AudioStreamIndex, info.SubtitleStreamIndex);
 
                     await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
+
+                    // Send a message to the DLNA device to notify what is the next track in the play list.
+                    var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl);
+                    await SendNextTrackMessage(newItemIndex, CancellationToken.None);
+
                     return;
                 }
 
@@ -468,8 +497,8 @@ namespace Emby.Dlna.PlayTo
                 _dlnaManager.GetDefaultProfile();
 
             var mediaSources = item is IHasMediaSources
-                ? _mediaSourceManager.GetStaticMediaSources(item, true, user)
-                : new List<MediaSourceInfo>();
+                ? _mediaSourceManager.GetStaticMediaSources(item, true, user).ToArray()
+                : Array.Empty<MediaSourceInfo>();
 
             var playlistItem = GetPlaylistItem(item, mediaSources, profile, _session.DeviceId, mediaSourceId, audioStreamIndex, subtitleStreamIndex);
             playlistItem.StreamInfo.StartPositionTicks = startPostionTicks;
@@ -502,8 +531,8 @@ namespace Emby.Dlna.PlayTo
 
             if (streamInfo.MediaType == DlnaProfileType.Audio)
             {
-                return new ContentFeatureBuilder(profile)
-                    .BuildAudioHeader(
+                return ContentFeatureBuilder.BuildAudioHeader(
+                        profile,
                         streamInfo.Container,
                         streamInfo.TargetAudioCodec.FirstOrDefault(),
                         streamInfo.TargetAudioBitrate,
@@ -517,8 +546,8 @@ namespace Emby.Dlna.PlayTo
 
             if (streamInfo.MediaType == DlnaProfileType.Video)
             {
-                var list = new ContentFeatureBuilder(profile)
-                    .BuildVideoHeader(
+                var list = ContentFeatureBuilder.BuildVideoHeader(
+                        profile,
                         streamInfo.Container,
                         streamInfo.TargetVideoCodec.FirstOrDefault(),
                         streamInfo.TargetAudioCodec.FirstOrDefault(),
@@ -548,7 +577,7 @@ namespace Emby.Dlna.PlayTo
             return null;
         }
 
-        private PlaylistItem GetPlaylistItem(BaseItem item, List<MediaSourceInfo> mediaSources, DeviceProfile profile, string deviceId, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
+        private PlaylistItem GetPlaylistItem(BaseItem item, MediaSourceInfo[] mediaSources, DeviceProfile profile, string deviceId, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
         {
             if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
             {
@@ -557,7 +586,7 @@ namespace Emby.Dlna.PlayTo
                     StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildVideoItem(new VideoOptions
                     {
                         ItemId = item.Id,
-                        MediaSources = mediaSources.ToArray(),
+                        MediaSources = mediaSources,
                         Profile = profile,
                         DeviceId = deviceId,
                         MaxBitrate = profile.MaxStreamingBitrate,
@@ -577,7 +606,7 @@ namespace Emby.Dlna.PlayTo
                     StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildAudioItem(new AudioOptions
                     {
                         ItemId = item.Id,
-                        MediaSources = mediaSources.ToArray(),
+                        MediaSources = mediaSources,
                         Profile = profile,
                         DeviceId = deviceId,
                         MaxBitrate = profile.MaxStreamingBitrate,
@@ -590,7 +619,7 @@ namespace Emby.Dlna.PlayTo
 
             if (string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase))
             {
-                return new PlaylistItemFactory().Create((Photo)item, profile);
+                return PlaylistItemFactory.Create((Photo)item, profile);
             }
 
             throw new ArgumentException("Unrecognized item type.");
@@ -626,6 +655,9 @@ namespace Emby.Dlna.PlayTo
 
             await _device.SetAvTransport(currentitem.StreamUrl, GetDlnaHeaders(currentitem), currentitem.Didl, cancellationToken).ConfigureAwait(false);
 
+            // Send a message to the DLNA device to notify what is the next track in the play list.
+            await SendNextTrackMessage(index, cancellationToken);
+
             var streamInfo = currentitem.StreamInfo;
             if (streamInfo.StartPositionTicks > 0 && EnableClientSideSeek(streamInfo))
             {
@@ -739,6 +771,10 @@ namespace Emby.Dlna.PlayTo
 
                     await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
 
+                    // Send a message to the DLNA device to notify what is the next track in the play list.
+                    var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl);
+                    await SendNextTrackMessage(newItemIndex, CancellationToken.None);
+
                     if (EnableClientSideSeek(newItem.StreamInfo))
                     {
                         await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
@@ -764,6 +800,10 @@ namespace Emby.Dlna.PlayTo
 
                     await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
 
+                    // Send a message to the DLNA device to notify what is the next track in the play list.
+                    var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl);
+                    await SendNextTrackMessage(newItemIndex, CancellationToken.None);
+
                     if (EnableClientSideSeek(newItem.StreamInfo) && newPosition > 0)
                     {
                         await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
@@ -774,13 +814,14 @@ namespace Emby.Dlna.PlayTo
 
         private async Task SeekAfterTransportChange(long positionTicks, CancellationToken cancellationToken)
         {
-            const int maxWait = 15000000;
-            const int interval = 500;
+            const int MaxWait = 15000000;
+            const int Interval = 500;
+
             var currentWait = 0;
-            while (_device.TransportState != TransportState.Playing && currentWait < maxWait)
+            while (_device.TransportState != TransportState.Playing && currentWait < MaxWait)
             {
-                await Task.Delay(interval).ConfigureAwait(false);
-                currentWait += interval;
+                await Task.Delay(Interval, cancellationToken).ConfigureAwait(false);
+                currentWait += Interval;
             }
 
             await _device.Seek(TimeSpan.FromTicks(positionTicks), cancellationToken).ConfigureAwait(false);
@@ -828,7 +869,7 @@ namespace Emby.Dlna.PlayTo
                 return SendPlayCommand(data as PlayRequest, cancellationToken);
             }
 
-            if (name == SessionMessageType.PlayState)
+            if (name == SessionMessageType.Playstate)
             {
                 return SendPlaystateCommand(data as PlaystateRequest, cancellationToken);
             }
@@ -898,16 +939,16 @@ namespace Emby.Dlna.PlayTo
 
                 var parts = url.Split('/');
 
-                for (var i = 0; i < parts.Length; i++)
+                for (var i = 0; i < parts.Length - 1; i++)
                 {
                     var part = parts[i];
 
                     if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase) ||
                         string.Equals(part, "videos", StringComparison.OrdinalIgnoreCase))
                     {
-                        if (parts.Length > i + 1)
+                        if (Guid.TryParse(parts[i + 1], out var result))
                         {
-                            return Guid.Parse(parts[i + 1]);
+                            return result;
                         }
                     }
                 }
@@ -946,7 +987,6 @@ namespace Emby.Dlna.PlayTo
                 request.MediaSourceId = values.GetValueOrDefault("MediaSourceId");
                 request.LiveStreamId = values.GetValueOrDefault("LiveStreamId");
                 request.IsDirectStream = string.Equals("true", values.GetValueOrDefault("Static"), StringComparison.OrdinalIgnoreCase);
-
                 request.AudioStreamIndex = GetIntValue(values, "AudioStreamIndex");
                 request.SubtitleStreamIndex = GetIntValue(values, "SubtitleStreamIndex");
                 request.StartPositionTicks = GetLongValue(values, "StartPositionTicks");

+ 31 - 27
Emby.Dlna/PlayTo/PlayToManager.cs

@@ -1,15 +1,15 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;
 using System.Globalization;
 using System.Linq;
-using System.Net;
 using System.Net.Http;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Data.Events;
 using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Net;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Dlna;
@@ -89,13 +89,10 @@ namespace Emby.Dlna.PlayTo
                 nt = string.Empty;
             }
 
-            string location = info.Location.ToString();
-
             // It has to report that it's a media renderer
-            if (usn.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) == -1 &&
-                nt.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) == -1)
+            if (!usn.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase)
+                && !nt.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase))
             {
-                // _logger.LogDebug("Upnp device {0} does not contain a MediaRenderer device (0).", location);
                 return;
             }
 
@@ -115,7 +112,7 @@ namespace Emby.Dlna.PlayTo
                     return;
                 }
 
-                await AddDevice(info, location, cancellationToken).ConfigureAwait(false);
+                await AddDevice(info, cancellationToken).ConfigureAwait(false);
             }
             catch (OperationCanceledException)
             {
@@ -130,39 +127,50 @@ namespace Emby.Dlna.PlayTo
             }
         }
 
-        private static string GetUuid(string usn)
+        internal static string GetUuid(string usn)
         {
             const string UuidStr = "uuid:";
             const string UuidColonStr = "::";
 
             var index = usn.IndexOf(UuidStr, StringComparison.OrdinalIgnoreCase);
+            if (index == -1)
+            {
+                return usn.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+            }
+
+            ReadOnlySpan<char> tmp = usn.AsSpan()[(index + UuidStr.Length)..];
+
+            index = tmp.IndexOf(UuidColonStr, StringComparison.OrdinalIgnoreCase);
             if (index != -1)
             {
-                return usn.Substring(index + UuidStr.Length);
+                tmp = tmp[..index];
             }
 
-            index = usn.IndexOf(UuidColonStr, StringComparison.OrdinalIgnoreCase);
+            index = tmp.IndexOf('{');
             if (index != -1)
             {
-                usn = usn.Substring(0, index + UuidColonStr.Length);
+                int endIndex = tmp.IndexOf('}');
+                if (endIndex != -1)
+                {
+                    tmp = tmp[(index + 1)..endIndex];
+                }
             }
 
-            return usn.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+            return tmp.ToString();
         }
 
-        private async Task AddDevice(UpnpDeviceInfo info, string location, CancellationToken cancellationToken)
+        private async Task AddDevice(UpnpDeviceInfo info, CancellationToken cancellationToken)
         {
             var uri = info.Location;
-            _logger.LogDebug("Attempting to create PlayToController from location {0}", location);
+            _logger.LogDebug("Attempting to create PlayToController from location {0}", uri);
 
-            _logger.LogDebug("Logging session activity from location {0}", location);
             if (info.Headers.TryGetValue("USN", out string uuid))
             {
                 uuid = GetUuid(uuid);
             }
             else
             {
-                uuid = location.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+                uuid = uri.ToString().GetMD5().ToString("N", CultureInfo.InvariantCulture);
             }
 
             var sessionInfo = _sessionManager.LogSessionActivity("DLNA", _appHost.ApplicationVersionString, uuid, null, uri.OriginalString, null);
@@ -172,20 +180,17 @@ namespace Emby.Dlna.PlayTo
             if (controller == null)
             {
                 var device = await Device.CreateuPnpDeviceAsync(uri, _httpClientFactory, _logger, cancellationToken).ConfigureAwait(false);
+                if (device == null)
+                {
+                    _logger.LogError("Ignoring device as xml response is invalid.");
+                    return;
+                }
 
                 string deviceName = device.Properties.Name;
 
                 _sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName);
 
-                string serverAddress;
-                if (info.LocalIpAddress == null || info.LocalIpAddress.Equals(IPAddress.Any) || info.LocalIpAddress.Equals(IPAddress.IPv6Any))
-                {
-                    serverAddress = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
-                }
-                else
-                {
-                    serverAddress = _appHost.GetLocalApiUrl(info.LocalIpAddress);
-                }
+                string serverAddress = _appHost.GetSmartApiUrl(info.RemoteIpAddress);
 
                 controller = new PlayToController(
                     sessionInfo,
@@ -201,7 +206,6 @@ namespace Emby.Dlna.PlayTo
                     _userDataManager,
                     _localization,
                     _mediaSourceManager,
-                    _config,
                     _mediaEncoder);
 
                 sessionInfo.AddController(controller);

+ 5 - 0
Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs

@@ -6,6 +6,11 @@ namespace Emby.Dlna.PlayTo
 {
     public class PlaybackProgressEventArgs : EventArgs
     {
+        public PlaybackProgressEventArgs(UBaseObject mediaInfo)
+        {
+            MediaInfo = mediaInfo;
+        }
+
         public UBaseObject MediaInfo { get; set; }
     }
 }

+ 5 - 0
Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs

@@ -6,6 +6,11 @@ namespace Emby.Dlna.PlayTo
 {
     public class PlaybackStartEventArgs : EventArgs
     {
+        public PlaybackStartEventArgs(UBaseObject mediaInfo)
+        {
+            MediaInfo = mediaInfo;
+        }
+
         public UBaseObject MediaInfo { get; set; }
     }
 }

+ 5 - 0
Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs

@@ -6,6 +6,11 @@ namespace Emby.Dlna.PlayTo
 {
     public class PlaybackStoppedEventArgs : EventArgs
     {
+        public PlaybackStoppedEventArgs(UBaseObject mediaInfo)
+        {
+            MediaInfo = mediaInfo;
+        }
+
         public UBaseObject MediaInfo { get; set; }
     }
 }

+ 2 - 0
Emby.Dlna/PlayTo/PlaylistItem.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using MediaBrowser.Model.Dlna;

+ 4 - 2
Emby.Dlna/PlayTo/PlaylistItemFactory.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System.IO;
@@ -8,9 +10,9 @@ using MediaBrowser.Model.Session;
 
 namespace Emby.Dlna.PlayTo
 {
-    public class PlaylistItemFactory
+    public static class PlaylistItemFactory
     {
-        public PlaylistItem Create(Photo item, DeviceProfile profile)
+        public static PlaylistItem Create(Photo item, DeviceProfile profile)
         {
             var playlistItem = new PlaylistItem
             {

+ 20 - 13
Emby.Dlna/PlayTo/SsdpHttpClient.cs

@@ -1,10 +1,10 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;
 using System.Globalization;
-using System.IO;
 using System.Net.Http;
-using System.Net.Http.Headers;
 using System.Net.Mime;
 using System.Text;
 using System.Threading;
@@ -45,11 +45,11 @@ namespace Emby.Dlna.PlayTo
                     header,
                     cancellationToken)
                 .ConfigureAwait(false);
-            await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
-            using var reader = new StreamReader(stream, Encoding.UTF8);
-            return XDocument.Parse(
-                await reader.ReadToEndAsync().ConfigureAwait(false),
-                LoadOptions.PreserveWhitespace);
+            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+            return await XDocument.LoadAsync(
+                stream,
+                LoadOptions.PreserveWhitespace,
+                cancellationToken).ConfigureAwait(false);
         }
 
         private static string NormalizeServiceUrl(string baseUrl, string serviceUrl)
@@ -60,7 +60,7 @@ namespace Emby.Dlna.PlayTo
                 return serviceUrl;
             }
 
-            if (!serviceUrl.StartsWith("/", StringComparison.Ordinal))
+            if (!serviceUrl.StartsWith('/'))
             {
                 serviceUrl = "/" + serviceUrl;
             }
@@ -94,11 +94,18 @@ namespace Emby.Dlna.PlayTo
             options.Headers.UserAgent.ParseAdd(USERAGENT);
             options.Headers.TryAddWithoutValidation("FriendlyName.DLNA.ORG", FriendlyName);
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
-            await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
-            using var reader = new StreamReader(stream, Encoding.UTF8);
-            return XDocument.Parse(
-                await reader.ReadToEndAsync().ConfigureAwait(false),
-                LoadOptions.PreserveWhitespace);
+            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+            try
+            {
+                return await XDocument.LoadAsync(
+                    stream,
+                    LoadOptions.PreserveWhitespace,
+                    cancellationToken).ConfigureAwait(false);
+            }
+            catch
+            {
+                return null;
+            }
         }
 
         private async Task<HttpResponseMessage> PostSoapDataAsync(

+ 17 - 19
Emby.Dlna/PlayTo/TransportCommands.cs

@@ -13,12 +13,10 @@ namespace Emby.Dlna.PlayTo
     public class TransportCommands
     {
         private const string CommandBase = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n" + "<SOAP-ENV:Envelope xmlns:SOAP-ENV=\"http://schemas.xmlsoap.org/soap/envelope/\" SOAP-ENV:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">" + "<SOAP-ENV:Body>" + "<m:{0} xmlns:m=\"{1}\">" + "{2}" + "</m:{0}>" + "</SOAP-ENV:Body></SOAP-ENV:Envelope>";
-        private List<StateVariable> _stateVariables = new List<StateVariable>();
-        private List<ServiceAction> _serviceActions = new List<ServiceAction>();
 
-        public List<StateVariable> StateVariables => _stateVariables;
+        public List<StateVariable> StateVariables { get; } = new List<StateVariable>();
 
-        public List<ServiceAction> ServiceActions => _serviceActions;
+        public List<ServiceAction> ServiceActions { get; } = new List<ServiceAction>();
 
         public static TransportCommands Create(XDocument document)
         {
@@ -48,7 +46,7 @@ namespace Emby.Dlna.PlayTo
         {
             var serviceAction = new ServiceAction
             {
-                Name = container.GetValue(UPnpNamespaces.Svc + "name"),
+                Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty,
             };
 
             var argumentList = serviceAction.ArgumentList;
@@ -70,15 +68,15 @@ namespace Emby.Dlna.PlayTo
 
             return new Argument
             {
-                Name = container.GetValue(UPnpNamespaces.Svc + "name"),
-                Direction = container.GetValue(UPnpNamespaces.Svc + "direction"),
-                RelatedStateVariable = container.GetValue(UPnpNamespaces.Svc + "relatedStateVariable")
+                Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty,
+                Direction = container.GetValue(UPnpNamespaces.Svc + "direction") ?? string.Empty,
+                RelatedStateVariable = container.GetValue(UPnpNamespaces.Svc + "relatedStateVariable") ?? string.Empty
             };
         }
 
         private static StateVariable FromXml(XElement container)
         {
-            var allowedValues = new List<string>();
+            var allowedValues = Array.Empty<string>();
             var element = container.Descendants(UPnpNamespaces.Svc + "allowedValueList")
                 .FirstOrDefault();
 
@@ -86,14 +84,14 @@ namespace Emby.Dlna.PlayTo
             {
                 var values = element.Descendants(UPnpNamespaces.Svc + "allowedValue");
 
-                allowedValues.AddRange(values.Select(child => child.Value));
+                allowedValues = values.Select(child => child.Value).ToArray();
             }
 
             return new StateVariable
             {
-                Name = container.GetValue(UPnpNamespaces.Svc + "name"),
-                DataType = container.GetValue(UPnpNamespaces.Svc + "dataType"),
-                AllowedValues = allowedValues.ToArray()
+                Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty,
+                DataType = container.GetValue(UPnpNamespaces.Svc + "dataType") ?? string.Empty,
+                AllowedValues = allowedValues
             };
         }
 
@@ -103,12 +101,12 @@ namespace Emby.Dlna.PlayTo
 
             foreach (var arg in action.ArgumentList)
             {
-                if (arg.Direction == "out")
+                if (string.Equals(arg.Direction, "out", StringComparison.Ordinal))
                 {
                     continue;
                 }
 
-                if (arg.Name == "InstanceID")
+                if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal))
                 {
                     stateString += BuildArgumentXml(arg, "0");
                 }
@@ -127,12 +125,12 @@ namespace Emby.Dlna.PlayTo
 
             foreach (var arg in action.ArgumentList)
             {
-                if (arg.Direction == "out")
+                if (string.Equals(arg.Direction, "out", StringComparison.Ordinal))
                 {
                     continue;
                 }
 
-                if (arg.Name == "InstanceID")
+                if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal))
                 {
                     stateString += BuildArgumentXml(arg, "0");
                 }
@@ -151,7 +149,7 @@ namespace Emby.Dlna.PlayTo
 
             foreach (var arg in action.ArgumentList)
             {
-                if (arg.Name == "InstanceID")
+                if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal))
                 {
                     stateString += BuildArgumentXml(arg, "0");
                 }
@@ -168,7 +166,7 @@ namespace Emby.Dlna.PlayTo
             return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamesapce, stateString);
         }
 
-        private string BuildArgumentXml(Argument argument, string value, string commandParameter = "")
+        private string BuildArgumentXml(Argument argument, string? value, string commandParameter = "")
         {
             var state = StateVariables.FirstOrDefault(a => string.Equals(a.Name, argument.RelatedStateVariable, StringComparison.OrdinalIgnoreCase));
 

+ 0 - 1
Emby.Dlna/PlayTo/TransportState.cs

@@ -1,5 +1,4 @@
 #pragma warning disable CS1591
-#pragma warning disable SA1602
 
 namespace Emby.Dlna.PlayTo
 {

+ 2 - 0
Emby.Dlna/PlayTo/uBaseObject.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;

+ 3 - 0
Emby.Dlna/Profiles/DefaultProfile.cs

@@ -1,5 +1,7 @@
 #pragma warning disable CS1591
 
+using System;
+using System.Globalization;
 using System.Linq;
 using MediaBrowser.Model.Dlna;
 
@@ -10,6 +12,7 @@ namespace Emby.Dlna.Profiles
     {
         public DefaultProfile()
         {
+            Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
             Name = "Generic Device";
 
             ProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma:*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*:image/jpeg:*,http-get:*:image/png:*,http-get:*:image/gif:*,http-get:*:image/tiff:*";

+ 2 - 2
Emby.Dlna/Profiles/SonyBravia2010Profile.cs

@@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles
 
             Identification = new DeviceIdentification
             {
-                FriendlyName = @"KDL-\d{2}[EHLNPB]X\d[01]\d.*",
+                FriendlyName = @"KDL-[0-9]{2}[EHLNPB]X[0-9][01][0-9].*",
                 Manufacturer = "Sony",
 
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     {
                         Name = "X-AV-Client-Info",
-                        Value = @".*KDL-\d{2}[EHLNPB]X\d[01]\d.*",
+                        Value = @".*KDL-[0-9]{2}[EHLNPB]X[0-9][01][0-9].*",
                         Match = HeaderMatchType.Regex
                     }
                 }

+ 2 - 2
Emby.Dlna/Profiles/SonyBravia2011Profile.cs

@@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles
 
             Identification = new DeviceIdentification
             {
-                FriendlyName = @"KDL-\d{2}([A-Z]X\d2\d|CX400).*",
+                FriendlyName = @"KDL-[0-9]{2}([A-Z]X[0-9]2[0-9]|CX400).*",
                 Manufacturer = "Sony",
 
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     {
                         Name = "X-AV-Client-Info",
-                        Value = @".*KDL-\d{2}([A-Z]X\d2\d|CX400).*",
+                        Value = @".*KDL-[0-9]{2}([A-Z]X[0-9]2[0-9]|CX400).*",
                         Match = HeaderMatchType.Regex
                     }
                 }

+ 2 - 2
Emby.Dlna/Profiles/SonyBravia2012Profile.cs

@@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles
 
             Identification = new DeviceIdentification
             {
-                FriendlyName = @"KDL-\d{2}[A-Z]X\d5(\d|G).*",
+                FriendlyName = @"KDL-[0-9]{2}[A-Z]X[0-9]5([0-9]|G).*",
                 Manufacturer = "Sony",
 
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     {
                         Name = "X-AV-Client-Info",
-                        Value = @".*KDL-\d{2}[A-Z]X\d5(\d|G).*",
+                        Value = @".*KDL-[0-9]{2}[A-Z]X[0-9]5([0-9]|G).*",
                         Match = HeaderMatchType.Regex
                     }
                 }

+ 2 - 2
Emby.Dlna/Profiles/SonyBravia2013Profile.cs

@@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles
 
             Identification = new DeviceIdentification
             {
-                FriendlyName = @"KDL-\d{2}[WR][5689]\d{2}A.*",
+                FriendlyName = @"KDL-[0-9]{2}[WR][5689][0-9]{2}A.*",
                 Manufacturer = "Sony",
 
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     {
                         Name = "X-AV-Client-Info",
-                        Value = @".*KDL-\d{2}[WR][5689]\d{2}A.*",
+                        Value = @".*KDL-[0-9]{2}[WR][5689][0-9]{2}A.*",
                         Match = HeaderMatchType.Regex
                     }
                 }

+ 2 - 2
Emby.Dlna/Profiles/SonyBravia2014Profile.cs

@@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles
 
             Identification = new DeviceIdentification
             {
-                FriendlyName = @"(KDL-\d{2}W[5-9]\d{2}B|KDL-\d{2}R480|XBR-\d{2}X[89]\d{2}B|KD-\d{2}[SX][89]\d{3}B).*",
+                FriendlyName = @"(KDL-[0-9]{2}W[5-9][0-9]{2}B|KDL-[0-9]{2}R480|XBR-[0-9]{2}X[89][0-9]{2}B|KD-[0-9]{2}[SX][89][0-9]{3}B).*",
                 Manufacturer = "Sony",
 
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     {
                         Name = "X-AV-Client-Info",
-                        Value = @".*(KDL-\d{2}W[5-9]\d{2}B|KDL-\d{2}R480|XBR-\d{2}X[89]\d{2}B|KD-\d{2}[SX][89]\d{3}B).*",
+                        Value = @".*(KDL-[0-9]{2}W[5-9][0-9]{2}B|KDL-[0-9]{2}R480|XBR-[0-9]{2}X[89][0-9]{2}B|KD-[0-9]{2}[SX][89][0-9]{3}B).*",
                         Match = HeaderMatchType.Regex
                     }
                 }

+ 2 - 2
Emby.Dlna/Profiles/SonyPs3Profile.cs

@@ -52,7 +52,7 @@ namespace Emby.Dlna.Profiles
                     Container = "ts,mpegts",
                     Type = DlnaProfileType.Video,
                     VideoCodec = "mpeg1video,mpeg2video,h264",
-                    AudioCodec = "ac3,mp2,mp3,aac"
+                    AudioCodec = "aac,ac3,mp2"
                 },
                 new DirectPlayProfile
                 {
@@ -92,7 +92,7 @@ namespace Emby.Dlna.Profiles
                 {
                     Container = "ts",
                     VideoCodec = "h264",
-                    AudioCodec = "ac3,aac,mp3",
+                    AudioCodec = "aac,ac3,mp2",
                     Type = DlnaProfileType.Video
                 },
                 new TranscodingProfile

+ 2 - 2
Emby.Dlna/Profiles/SonyPs4Profile.cs

@@ -52,7 +52,7 @@ namespace Emby.Dlna.Profiles
                     Container = "ts,mpegts",
                     Type = DlnaProfileType.Video,
                     VideoCodec = "mpeg1video,mpeg2video,h264",
-                    AudioCodec = "ac3,mp2,mp3,aac"
+                    AudioCodec = "aac,ac3,mp2"
                 },
                 new DirectPlayProfile
                 {
@@ -94,7 +94,7 @@ namespace Emby.Dlna.Profiles
                 {
                     Container = "ts",
                     VideoCodec = "h264",
-                    AudioCodec = "mp3",
+                    AudioCodec = "aac,ac3,mp2",
                     Type = DlnaProfileType.Video
                 },
                 new TranscodingProfile

+ 2 - 2
Emby.Dlna/Profiles/Xml/Sony Bravia (2010).xml

@@ -3,10 +3,10 @@
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2010)</Name>
   <Identification>
-    <FriendlyName>KDL-\d{2}[EHLNPB]X\d[01]\d.*</FriendlyName>
+    <FriendlyName>KDL-[0-9]{2}[EHLNPB]X[0-9][01][0-9].*</FriendlyName>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
-      <HttpHeaderInfo name="X-AV-Client-Info" value=".*KDL-\d{2}[EHLNPB]X\d[01]\d.*" match="Regex" />
+      <HttpHeaderInfo name="X-AV-Client-Info" value=".*KDL-[0-9]{2}[EHLNPB]X[0-9][01][0-9].*" match="Regex" />
     </Headers>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>

+ 2 - 2
Emby.Dlna/Profiles/Xml/Sony Bravia (2011).xml

@@ -3,10 +3,10 @@
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2011)</Name>
   <Identification>
-    <FriendlyName>KDL-\d{2}([A-Z]X\d2\d|CX400).*</FriendlyName>
+    <FriendlyName>KDL-[0-9]{2}([A-Z]X[0-9]2[0-9]|CX400).*</FriendlyName>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
-      <HttpHeaderInfo name="X-AV-Client-Info" value=".*KDL-\d{2}([A-Z]X\d2\d|CX400).*" match="Regex" />
+      <HttpHeaderInfo name="X-AV-Client-Info" value=".*KDL-[0-9]{2}([A-Z]X[0-9]2[0-9]|CX400).*" match="Regex" />
     </Headers>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>

+ 2 - 2
Emby.Dlna/Profiles/Xml/Sony Bravia (2012).xml

@@ -3,10 +3,10 @@
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2012)</Name>
   <Identification>
-    <FriendlyName>KDL-\d{2}[A-Z]X\d5(\d|G).*</FriendlyName>
+    <FriendlyName>KDL-[0-9]{2}[A-Z]X[0-9]5([0-9]|G).*</FriendlyName>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
-      <HttpHeaderInfo name="X-AV-Client-Info" value=".*KDL-\d{2}[A-Z]X\d5(\d|G).*" match="Regex" />
+      <HttpHeaderInfo name="X-AV-Client-Info" value=".*KDL-[0-9]{2}[A-Z]X[0-9]5([0-9]|G).*" match="Regex" />
     </Headers>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>

+ 2 - 2
Emby.Dlna/Profiles/Xml/Sony Bravia (2013).xml

@@ -3,10 +3,10 @@
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2013)</Name>
   <Identification>
-    <FriendlyName>KDL-\d{2}[WR][5689]\d{2}A.*</FriendlyName>
+    <FriendlyName>KDL-[0-9]{2}[WR][5689][0-9]{2}A.*</FriendlyName>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
-      <HttpHeaderInfo name="X-AV-Client-Info" value=".*KDL-\d{2}[WR][5689]\d{2}A.*" match="Regex" />
+      <HttpHeaderInfo name="X-AV-Client-Info" value=".*KDL-[0-9]{2}[WR][5689][0-9]{2}A.*" match="Regex" />
     </Headers>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>

+ 2 - 2
Emby.Dlna/Profiles/Xml/Sony Bravia (2014).xml

@@ -3,10 +3,10 @@
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2014)</Name>
   <Identification>
-    <FriendlyName>(KDL-\d{2}W[5-9]\d{2}B|KDL-\d{2}R480|XBR-\d{2}X[89]\d{2}B|KD-\d{2}[SX][89]\d{3}B).*</FriendlyName>
+    <FriendlyName>(KDL-[0-9]{2}W[5-9][0-9]{2}B|KDL-[0-9]{2}R480|XBR-[0-9]{2}X[89][0-9]{2}B|KD-[0-9]{2}[SX][89][0-9]{3}B).*</FriendlyName>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
-      <HttpHeaderInfo name="X-AV-Client-Info" value=".*(KDL-\d{2}W[5-9]\d{2}B|KDL-\d{2}R480|XBR-\d{2}X[89]\d{2}B|KD-\d{2}[SX][89]\d{3}B).*" match="Regex" />
+      <HttpHeaderInfo name="X-AV-Client-Info" value=".*(KDL-[0-9]{2}W[5-9][0-9]{2}B|KDL-[0-9]{2}R480|XBR-[0-9]{2}X[89][0-9]{2}B|KD-[0-9]{2}[SX][89][0-9]{3}B).*" match="Regex" />
     </Headers>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>

+ 2 - 2
Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml

@@ -38,7 +38,7 @@
   <XmlRootAttributes />
   <DirectPlayProfiles>
     <DirectPlayProfile container="avi" audioCodec="mp2,mp3" videoCodec="mpeg4" type="Video" />
-    <DirectPlayProfile container="ts,mpegts" audioCodec="ac3,mp2,mp3,aac" videoCodec="mpeg1video,mpeg2video,h264" type="Video" />
+    <DirectPlayProfile container="ts,mpegts" audioCodec="aac,ac3,mp2" videoCodec="mpeg1video,mpeg2video,h264" type="Video" />
     <DirectPlayProfile container="mpeg" audioCodec="mp2" videoCodec="mpeg1video,mpeg2video" type="Video" />
     <DirectPlayProfile container="mp4" audioCodec="aac,ac3" videoCodec="h264,mpeg4" type="Video" />
     <DirectPlayProfile container="aac,mp3,wav" type="Audio" />
@@ -46,7 +46,7 @@
   </DirectPlayProfiles>
   <TranscodingProfiles>
     <TranscodingProfile container="mp3" type="Audio" audioCodec="mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
-    <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="ac3,aac,mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
+    <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="aac,ac3,mp2" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
     <TranscodingProfile container="jpeg" type="Photo" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
   </TranscodingProfiles>
   <ContainerProfiles>

+ 2 - 2
Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml

@@ -38,7 +38,7 @@
   <XmlRootAttributes />
   <DirectPlayProfiles>
     <DirectPlayProfile container="avi" audioCodec="mp2,mp3" videoCodec="mpeg4" type="Video" />
-    <DirectPlayProfile container="ts,mpegts" audioCodec="ac3,mp2,mp3,aac" videoCodec="mpeg1video,mpeg2video,h264" type="Video" />
+    <DirectPlayProfile container="ts,mpegts" audioCodec="aac,ac3,mp2" videoCodec="mpeg1video,mpeg2video,h264" type="Video" />
     <DirectPlayProfile container="mpeg" audioCodec="mp2" videoCodec="mpeg1video,mpeg2video" type="Video" />
     <DirectPlayProfile container="mp4,mkv,m4v" audioCodec="aac,ac3" videoCodec="h264,mpeg4" type="Video" />
     <DirectPlayProfile container="aac,mp3,wav" type="Audio" />
@@ -46,7 +46,7 @@
   </DirectPlayProfiles>
   <TranscodingProfiles>
     <TranscodingProfile container="mp3" type="Audio" audioCodec="mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Bytes" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
-    <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
+    <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="aac,ac3,mp2" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
     <TranscodingProfile container="jpeg" type="Photo" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
   </TranscodingProfiles>
   <ContainerProfiles>

+ 2 - 0
Emby.Dlna/Properties/AssemblyInfo.cs

@@ -1,5 +1,6 @@
 using System.Reflection;
 using System.Resources;
+using System.Runtime.CompilerServices;
 
 // General Information about an assembly is controlled through the following
 // set of attributes. Change these attribute values to modify the information
@@ -13,6 +14,7 @@ using System.Resources;
 [assembly: AssemblyTrademark("")]
 [assembly: AssemblyCulture("")]
 [assembly: NeutralResourcesLanguage("en")]
+[assembly: InternalsVisibleTo("Jellyfin.Dlna.Tests")]
 
 // Version information for an assembly consists of the following four values:
 //

+ 3 - 18
Emby.Dlna/Server/DescriptionXmlBuilder.cs

@@ -40,8 +40,6 @@ namespace Emby.Dlna.Server
             _serverId = serverId;
         }
 
-        private static bool EnableAbsoluteUrls => false;
-
         public string GetXml()
         {
             var builder = new StringBuilder();
@@ -75,13 +73,6 @@ namespace Emby.Dlna.Server
             builder.Append("<minor>0</minor>");
             builder.Append("</specVersion>");
 
-            if (!EnableAbsoluteUrls)
-            {
-                builder.Append("<URLBase>")
-                    .Append(SecurityElement.Escape(_serverAddress))
-                    .Append("</URLBase>");
-            }
-
             AppendDeviceInfo(builder);
 
             builder.Append("</root>");
@@ -257,16 +248,10 @@ namespace Emby.Dlna.Server
                 return string.Empty;
             }
 
-            url = url.TrimStart('/');
-
-            url = "/dlna/" + _serverUdn + "/" + url;
-
-            if (EnableAbsoluteUrls)
-            {
-                url = _serverAddress.TrimEnd('/') + url;
-            }
+            url = _serverAddress.TrimEnd('/') + "/dlna/" + _serverUdn + "/" + url.TrimStart('/');
 
-            return SecurityElement.Escape(url);
+            // TODO: @bond remove null-coalescing operator when https://github.com/dotnet/runtime/pull/52442 is merged/released
+            return SecurityElement.Escape(url) ?? string.Empty;
         }
 
         private IEnumerable<DeviceIcon> GetIcons()

+ 7 - 10
Emby.Dlna/Service/BaseControlHandler.cs

@@ -6,9 +6,9 @@ using System.IO;
 using System.Text;
 using System.Threading.Tasks;
 using System.Xml;
+using Diacritics.Extensions;
 using Emby.Dlna.Didl;
 using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Extensions;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Dlna.Service
@@ -47,9 +47,9 @@ namespace Emby.Dlna.Service
 
         private async Task<ControlResponse> ProcessControlRequestInternalAsync(ControlRequest request)
         {
-            ControlRequestInfo requestInfo = null;
+            ControlRequestInfo? requestInfo = null;
 
-            using (var streamReader = new StreamReader(request.InputXml))
+            using (var streamReader = new StreamReader(request.InputXml, Encoding.UTF8))
             {
                 var readerSettings = new XmlReaderSettings()
                 {
@@ -95,11 +95,7 @@ namespace Emby.Dlna.Service
 
             var xml = builder.ToString().Replace("xmlns:m=", "xmlns:u=", StringComparison.Ordinal);
 
-            var controlResponse = new ControlResponse
-            {
-                Xml = xml,
-                IsSuccessful = true
-            };
+            var controlResponse = new ControlResponse(xml, true);
 
             controlResponse.Headers.Add("EXT", string.Empty);
 
@@ -151,7 +147,7 @@ namespace Emby.Dlna.Service
 
         private async Task<ControlRequestInfo> ParseBodyTagAsync(XmlReader reader)
         {
-            string namespaceURI = null, localName = null;
+            string? namespaceURI = null, localName = null;
 
             await reader.MoveToContentAsync().ConfigureAwait(false);
             await reader.ReadAsync().ConfigureAwait(false);
@@ -169,6 +165,7 @@ namespace Emby.Dlna.Service
                         var result = new ControlRequestInfo(localName, namespaceURI);
                         using var subReader = reader.ReadSubtree();
                         await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false);
+                        return result;
                     }
                     else
                     {
@@ -209,7 +206,7 @@ namespace Emby.Dlna.Service
             }
         }
 
-        protected abstract void WriteResult(string methodName, IDictionary<string, string> methodParams, XmlWriter xmlWriter);
+        protected abstract void WriteResult(string methodName, IReadOnlyDictionary<string, string> methodParams, XmlWriter xmlWriter);
 
         private void LogRequest(ControlRequest request)
         {

+ 1 - 5
Emby.Dlna/Service/ControlErrorHandler.cs

@@ -46,11 +46,7 @@ namespace Emby.Dlna.Service
                 writer.WriteEndDocument();
             }
 
-            return new ControlResponse
-            {
-                Xml = builder.ToString(),
-                IsSuccessful = false
-            };
+            return new ControlResponse(builder.ToString(), false);
         }
     }
 }

+ 4 - 2
Emby.Dlna/Ssdp/DeviceDiscovery.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 
 using System;
@@ -69,7 +71,7 @@ namespace Emby.Dlna.Ssdp
         {
             lock (_syncLock)
             {
-                if (_listenerCount > 0 && _deviceLocator == null)
+                if (_listenerCount > 0 && _deviceLocator == null && _commsServer != null)
                 {
                     _deviceLocator = new SsdpDeviceLocator(_commsServer);
 
@@ -104,7 +106,7 @@ namespace Emby.Dlna.Ssdp
                 {
                     Location = e.DiscoveredDevice.DescriptionLocation,
                     Headers = headers,
-                    LocalIpAddress = e.LocalIpAddress
+                    RemoteIpAddress = e.RemoteIpAddress
                 });
 
             DeviceDiscoveredInternal?.Invoke(this, args);

+ 3 - 3
Emby.Dlna/Ssdp/SsdpExtensions.cs

@@ -7,21 +7,21 @@ namespace Emby.Dlna.Ssdp
 {
     public static class SsdpExtensions
     {
-        public static string GetValue(this XElement container, XName name)
+        public static string? GetValue(this XElement container, XName name)
         {
             var node = container.Element(name);
 
             return node?.Value;
         }
 
-        public static string GetAttributeValue(this XElement container, XName name)
+        public static string? GetAttributeValue(this XElement container, XName name)
         {
             var node = container.Attribute(name);
 
             return node?.Value;
         }
 
-        public static string GetDescendantValue(this XElement container, XName name)
+        public static string? GetDescendantValue(this XElement container, XName name)
             => container.Descendants(name).FirstOrDefault()?.Value;
     }
 }

+ 2 - 8
Emby.Drawing/Emby.Drawing.csproj

@@ -6,11 +6,10 @@
   </PropertyGroup>
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
-    <Nullable>enable</Nullable>
+    <AnalysisMode>AllDisabledByDefault</AnalysisMode>
   </PropertyGroup>
 
   <ItemGroup>
@@ -25,14 +24,9 @@
 
   <!-- Code analysers-->
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
     <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
     <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
   </ItemGroup>
 
-  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
-  </PropertyGroup>
-
 </Project>

+ 102 - 23
Emby.Drawing/ImageProcessor.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
 using System.Linq;
+using System.Text;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Extensions;
@@ -171,21 +172,31 @@ namespace Emby.Drawing
                 return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
             }
 
-            ImageDimensions newSize = ImageHelper.GetNewImageSize(options, null);
             int quality = options.Quality;
 
             ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
-            string cacheFilePath = GetCacheFilePath(originalImagePath, newSize, quality, dateModified, outputFormat, options.AddPlayedIndicator, options.PercentPlayed, options.UnplayedCount, options.Blur, options.BackgroundColor, options.ForegroundLayer);
+            string cacheFilePath = GetCacheFilePath(
+                originalImagePath,
+                options.Width,
+                options.Height,
+                options.MaxWidth,
+                options.MaxHeight,
+                options.FillWidth,
+                options.FillHeight,
+                quality,
+                dateModified,
+                outputFormat,
+                options.AddPlayedIndicator,
+                options.PercentPlayed,
+                options.UnplayedCount,
+                options.Blur,
+                options.BackgroundColor,
+                options.ForegroundLayer);
 
             try
             {
                 if (!File.Exists(cacheFilePath))
                 {
-                    if (options.CropWhiteSpace && !SupportsTransparency(originalImagePath))
-                    {
-                        options.CropWhiteSpace = false;
-                    }
-
                     string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
 
                     if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
@@ -246,48 +257,111 @@ namespace Emby.Drawing
         /// <summary>
         /// Gets the cache file path based on a set of parameters.
         /// </summary>
-        private string GetCacheFilePath(string originalPath, ImageDimensions outputSize, int quality, DateTime dateModified, ImageFormat format, bool addPlayedIndicator, double percentPlayed, int? unwatchedCount, int? blur, string backgroundColor, string foregroundLayer)
+        private string GetCacheFilePath(
+            string originalPath,
+            int? width,
+            int? height,
+            int? maxWidth,
+            int? maxHeight,
+            int? fillWidth,
+            int? fillHeight,
+            int quality,
+            DateTime dateModified,
+            ImageFormat format,
+            bool addPlayedIndicator,
+            double percentPlayed,
+            int? unwatchedCount,
+            int? blur,
+            string backgroundColor,
+            string foregroundLayer)
         {
-            var filename = originalPath
-                + "width=" + outputSize.Width
-                + "height=" + outputSize.Height
-                + "quality=" + quality
-                + "datemodified=" + dateModified.Ticks
-                + "f=" + format;
+            var filename = new StringBuilder(256);
+            filename.Append(originalPath);
+
+            filename.Append(",quality=");
+            filename.Append(quality);
+
+            filename.Append(",datemodified=");
+            filename.Append(dateModified.Ticks);
+
+            filename.Append(",f=");
+            filename.Append(format);
+
+            if (width.HasValue)
+            {
+                filename.Append(",width=");
+                filename.Append(width.Value);
+            }
+
+            if (height.HasValue)
+            {
+                filename.Append(",height=");
+                filename.Append(height.Value);
+            }
+
+            if (maxWidth.HasValue)
+            {
+                filename.Append(",maxwidth=");
+                filename.Append(maxWidth.Value);
+            }
+
+            if (maxHeight.HasValue)
+            {
+                filename.Append(",maxheight=");
+                filename.Append(maxHeight.Value);
+            }
+
+            if (fillWidth.HasValue)
+            {
+                filename.Append(",fillwidth=");
+                filename.Append(fillWidth.Value);
+            }
+
+            if (fillHeight.HasValue)
+            {
+                filename.Append(",fillheight=");
+                filename.Append(fillHeight.Value);
+            }
 
             if (addPlayedIndicator)
             {
-                filename += "pl=true";
+                filename.Append(",pl=true");
             }
 
             if (percentPlayed > 0)
             {
-                filename += "p=" + percentPlayed;
+                filename.Append(",p=");
+                filename.Append(percentPlayed);
             }
 
             if (unwatchedCount.HasValue)
             {
-                filename += "p=" + unwatchedCount.Value;
+                filename.Append(",p=");
+                filename.Append(unwatchedCount.Value);
             }
 
             if (blur.HasValue)
             {
-                filename += "blur=" + blur.Value;
+                filename.Append(",blur=");
+                filename.Append(blur.Value);
             }
 
             if (!string.IsNullOrEmpty(backgroundColor))
             {
-                filename += "b=" + backgroundColor;
+                filename.Append(",b=");
+                filename.Append(backgroundColor);
             }
 
             if (!string.IsNullOrEmpty(foregroundLayer))
             {
-                filename += "fl=" + foregroundLayer;
+                filename.Append(",fl=");
+                filename.Append(foregroundLayer);
             }
 
-            filename += "v=" + Version;
+            filename.Append(",v=");
+            filename.Append(Version);
 
-            return GetCachePath(ResizedImageCachePath, filename, "." + format.ToString().ToLowerInvariant());
+            return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant());
         }
 
         /// <inheritdoc />
@@ -352,8 +426,13 @@ namespace Emby.Drawing
         }
 
         /// <inheritdoc />
-        public string GetImageCacheTag(User user)
+        public string? GetImageCacheTag(User user)
         {
+            if (user.ProfileImage == null)
+            {
+                return null;
+            }
+
             return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
                 .ToString("N", CultureInfo.InvariantCulture);
         }

+ 1 - 1
Emby.Drawing/NullImageEncoder.cs

@@ -32,7 +32,7 @@ namespace Emby.Drawing
             => throw new NotImplementedException();
 
         /// <inheritdoc />
-        public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat selectedOutputFormat)
+        public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
         {
             throw new NotImplementedException();
         }

+ 12 - 3
Emby.Naming/Audio/AlbumParser.cs

@@ -1,6 +1,3 @@
-#nullable enable
-#pragma warning disable CS1591
-
 using System;
 using System.Globalization;
 using System.IO;
@@ -9,15 +6,27 @@ using Emby.Naming.Common;
 
 namespace Emby.Naming.Audio
 {
+    /// <summary>
+    /// Helper class to determine if Album is multipart.
+    /// </summary>
     public class AlbumParser
     {
         private readonly NamingOptions _options;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AlbumParser"/> class.
+        /// </summary>
+        /// <param name="options">Naming options containing AlbumStackingPrefixes.</param>
         public AlbumParser(NamingOptions options)
         {
             _options = options;
         }
 
+        /// <summary>
+        /// Function that determines if album is multipart.
+        /// </summary>
+        /// <param name="path">Path to file.</param>
+        /// <returns>True if album is multipart.</returns>
         public bool IsMultiPart(string path)
         {
             var filename = Path.GetFileName(path);

+ 12 - 6
Emby.Naming/Audio/AudioFileParser.cs

@@ -1,19 +1,25 @@
-#nullable enable
-#pragma warning disable CS1591
-
 using System;
 using System.IO;
-using System.Linq;
 using Emby.Naming.Common;
+using Jellyfin.Extensions;
 
 namespace Emby.Naming.Audio
 {
+    /// <summary>
+    /// Static helper class to determine if file at path is audio file.
+    /// </summary>
     public static class AudioFileParser
     {
+        /// <summary>
+        /// Static helper method to determine if file at path is audio file.
+        /// </summary>
+        /// <param name="path">Path to file.</param>
+        /// <param name="options"><see cref="NamingOptions"/> containing AudioFileExtensions.</param>
+        /// <returns>True if file at path is audio file.</returns>
         public static bool IsAudioFile(string path, NamingOptions options)
         {
-            var extension = Path.GetExtension(path);
-            return options.AudioFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
+            var extension = Path.GetExtension(path.AsSpan());
+            return options.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
         }
     }
 }

+ 16 - 7
Emby.Naming/AudioBook/AudioBookFileInfo.cs

@@ -7,6 +7,21 @@ namespace Emby.Naming.AudioBook
     /// </summary>
     public class AudioBookFileInfo : IComparable<AudioBookFileInfo>
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AudioBookFileInfo"/> class.
+        /// </summary>
+        /// <param name="path">Path to audiobook file.</param>
+        /// <param name="container">File type.</param>
+        /// <param name="partNumber">Number of part this file represents.</param>
+        /// <param name="chapterNumber">Number of chapter this file represents.</param>
+        public AudioBookFileInfo(string path, string container, int? partNumber = default, int? chapterNumber = default)
+        {
+            Path = path;
+            Container = container;
+            PartNumber = partNumber;
+            ChapterNumber = chapterNumber;
+        }
+
         /// <summary>
         /// Gets or sets the path.
         /// </summary>
@@ -31,14 +46,8 @@ namespace Emby.Naming.AudioBook
         /// <value>The chapter number.</value>
         public int? ChapterNumber { get; set; }
 
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is a directory.
-        /// </summary>
-        /// <value>The type.</value>
-        public bool IsDirectory { get; set; }
-
         /// <inheritdoc />
-        public int CompareTo(AudioBookFileInfo other)
+        public int CompareTo(AudioBookFileInfo? other)
         {
             if (ReferenceEquals(this, other))
             {

+ 12 - 5
Emby.Naming/AudioBook/AudioBookFilePathParser.cs

@@ -1,6 +1,3 @@
-#nullable enable
-#pragma warning disable CS1591
-
 using System.Globalization;
 using System.IO;
 using System.Text.RegularExpressions;
@@ -8,15 +5,27 @@ using Emby.Naming.Common;
 
 namespace Emby.Naming.AudioBook
 {
+    /// <summary>
+    /// Parser class to extract part and/or chapter number from audiobook filename.
+    /// </summary>
     public class AudioBookFilePathParser
     {
         private readonly NamingOptions _options;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AudioBookFilePathParser"/> class.
+        /// </summary>
+        /// <param name="options">Naming options containing AudioBookPartsExpressions.</param>
         public AudioBookFilePathParser(NamingOptions options)
         {
             _options = options;
         }
 
+        /// <summary>
+        /// Based on regex determines if filename includes part/chapter number.
+        /// </summary>
+        /// <param name="path">Path to audiobook file.</param>
+        /// <returns>Returns <see cref="AudioBookFilePathParser"/> object.</returns>
         public AudioBookFilePathParserResult Parse(string path)
         {
             AudioBookFilePathParserResult result = default;
@@ -52,8 +61,6 @@ namespace Emby.Naming.AudioBook
                 }
             }
 
-            result.Success = result.ChapterNumber.HasValue || result.PartNumber.HasValue;
-
             return result;
         }
     }

+ 9 - 5
Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs

@@ -1,14 +1,18 @@
-#nullable enable
-#pragma warning disable CS1591
-
 namespace Emby.Naming.AudioBook
 {
+    /// <summary>
+    /// Data object for passing result of audiobook part/chapter extraction.
+    /// </summary>
     public struct AudioBookFilePathParserResult
     {
+        /// <summary>
+        /// Gets or sets optional number of path extracted from audiobook filename.
+        /// </summary>
         public int? PartNumber { get; set; }
 
+        /// <summary>
+        /// Gets or sets optional number of chapter extracted from audiobook filename.
+        /// </summary>
         public int? ChapterNumber { get; set; }
-
-        public bool Success { get; set; }
     }
 }

+ 14 - 7
Emby.Naming/AudioBook/AudioBookInfo.cs

@@ -10,11 +10,18 @@ namespace Emby.Naming.AudioBook
         /// <summary>
         /// Initializes a new instance of the <see cref="AudioBookInfo" /> class.
         /// </summary>
-        public AudioBookInfo()
+        /// <param name="name">Name of audiobook.</param>
+        /// <param name="year">Year of audiobook release.</param>
+        /// <param name="files">List of files composing the actual audiobook.</param>
+        /// <param name="extras">List of extra files.</param>
+        /// <param name="alternateVersions">Alternative version of files.</param>
+        public AudioBookInfo(string name, int? year, IReadOnlyList<AudioBookFileInfo> files, IReadOnlyList<AudioBookFileInfo> extras, IReadOnlyList<AudioBookFileInfo> alternateVersions)
         {
-            Files = new List<AudioBookFileInfo>();
-            Extras = new List<AudioBookFileInfo>();
-            AlternateVersions = new List<AudioBookFileInfo>();
+            Name = name;
+            Year = year;
+            Files = files;
+            Extras = extras;
+            AlternateVersions = alternateVersions;
         }
 
         /// <summary>
@@ -32,18 +39,18 @@ namespace Emby.Naming.AudioBook
         /// Gets or sets the files.
         /// </summary>
         /// <value>The files.</value>
-        public List<AudioBookFileInfo> Files { get; set; }
+        public IReadOnlyList<AudioBookFileInfo> Files { get; set; }
 
         /// <summary>
         /// Gets or sets the extras.
         /// </summary>
         /// <value>The extras.</value>
-        public List<AudioBookFileInfo> Extras { get; set; }
+        public IReadOnlyList<AudioBookFileInfo> Extras { get; set; }
 
         /// <summary>
         /// Gets or sets the alternate versions.
         /// </summary>
         /// <value>The alternate versions.</value>
-        public List<AudioBookFileInfo> AlternateVersions { get; set; }
+        public IReadOnlyList<AudioBookFileInfo> AlternateVersions { get; set; }
     }
 }

+ 117 - 12
Emby.Naming/AudioBook/AudioBookListResolver.cs

@@ -1,6 +1,6 @@
-#pragma warning disable CS1591
-
+using System;
 using System.Collections.Generic;
+using System.IO;
 using System.Linq;
 using Emby.Naming.Common;
 using Emby.Naming.Video;
@@ -8,40 +8,145 @@ using MediaBrowser.Model.IO;
 
 namespace Emby.Naming.AudioBook
 {
+    /// <summary>
+    /// Class used to resolve Name, Year, alternative files and extras from stack of files.
+    /// </summary>
     public class AudioBookListResolver
     {
         private readonly NamingOptions _options;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AudioBookListResolver"/> class.
+        /// </summary>
+        /// <param name="options">Naming options passed along to <see cref="AudioBookResolver"/> and <see cref="AudioBookNameParser"/>.</param>
         public AudioBookListResolver(NamingOptions options)
         {
             _options = options;
         }
 
+        /// <summary>
+        /// Resolves Name, Year and differentiate alternative files and extras from regular audiobook files.
+        /// </summary>
+        /// <param name="files">List of files related to audiobook.</param>
+        /// <returns>Returns IEnumerable of <see cref="AudioBookInfo"/>.</returns>
         public IEnumerable<AudioBookInfo> Resolve(IEnumerable<FileSystemMetadata> files)
         {
             var audioBookResolver = new AudioBookResolver(_options);
 
+            // File with empty fullname will be sorted out here.
             var audiobookFileInfos = files
-                .Select(i => audioBookResolver.Resolve(i.FullName, i.IsDirectory))
-                .Where(i => i != null)
+                .Select(i => audioBookResolver.Resolve(i.FullName))
+                .OfType<AudioBookFileInfo>()
                 .ToList();
 
-            // Filter out all extras, otherwise they could cause stacks to not be resolved
-            // See the unit test TestStackedWithTrailer
-            var metadata = audiobookFileInfos
-                .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
-
             var stackResult = new StackResolver(_options)
-                .ResolveAudioBooks(metadata);
+                .ResolveAudioBooks(audiobookFileInfos);
 
             foreach (var stack in stackResult)
             {
-                var stackFiles = stack.Files.Select(i => audioBookResolver.Resolve(i, stack.IsDirectoryStack)).ToList();
+                var stackFiles = stack.Files
+                    .Select(i => audioBookResolver.Resolve(i))
+                    .OfType<AudioBookFileInfo>()
+                    .ToList();
+
                 stackFiles.Sort();
-                var info = new AudioBookInfo { Files = stackFiles, Name = stack.Name };
+
+                var nameParserResult = new AudioBookNameParser(_options).Parse(stack.Name);
+
+                FindExtraAndAlternativeFiles(ref stackFiles, out var extras, out var alternativeVersions, nameParserResult);
+
+                var info = new AudioBookInfo(
+                    nameParserResult.Name,
+                    nameParserResult.Year,
+                    stackFiles,
+                    extras,
+                    alternativeVersions);
 
                 yield return info;
             }
         }
+
+        private void FindExtraAndAlternativeFiles(ref List<AudioBookFileInfo> stackFiles, out List<AudioBookFileInfo> extras, out List<AudioBookFileInfo> alternativeVersions, AudioBookNameParserResult nameParserResult)
+        {
+            extras = new List<AudioBookFileInfo>();
+            alternativeVersions = new List<AudioBookFileInfo>();
+
+            var haveChaptersOrPages = stackFiles.Any(x => x.ChapterNumber != null || x.PartNumber != null);
+            var groupedBy = stackFiles.GroupBy(file => new { file.ChapterNumber, file.PartNumber });
+            var nameWithReplacedDots = nameParserResult.Name.Replace(' ', '.');
+
+            foreach (var group in groupedBy)
+            {
+                if (group.Key.ChapterNumber == null && group.Key.PartNumber == null)
+                {
+                    if (group.Count() > 1 || haveChaptersOrPages)
+                    {
+                        var ex = new List<AudioBookFileInfo>();
+                        var alt = new List<AudioBookFileInfo>();
+
+                        foreach (var audioFile in group)
+                        {
+                            var name = Path.GetFileNameWithoutExtension(audioFile.Path);
+                            if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) ||
+                                name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) ||
+                                name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase))
+                            {
+                                alt.Add(audioFile);
+                            }
+                            else
+                            {
+                                ex.Add(audioFile);
+                            }
+                        }
+
+                        if (ex.Count > 0)
+                        {
+                            var extra = ex
+                                .OrderBy(x => x.Container)
+                                .ThenBy(x => x.Path)
+                                .ToList();
+
+                            stackFiles = stackFiles.Except(extra).ToList();
+                            extras.AddRange(extra);
+                        }
+
+                        if (alt.Count > 0)
+                        {
+                            var alternatives = alt
+                                .OrderBy(x => x.Container)
+                                .ThenBy(x => x.Path)
+                                .ToList();
+
+                            var main = FindMainAudioBookFile(alternatives, nameParserResult.Name);
+                            alternatives.Remove(main);
+                            stackFiles = stackFiles.Except(alternatives).ToList();
+                            alternativeVersions.AddRange(alternatives);
+                        }
+                    }
+                }
+                else if (group.Count() > 1)
+                {
+                    var alternatives = group
+                        .OrderBy(x => x.Container)
+                        .ThenBy(x => x.Path)
+                        .Skip(1)
+                        .ToList();
+
+                    stackFiles = stackFiles.Except(alternatives).ToList();
+                    alternativeVersions.AddRange(alternatives);
+                }
+            }
+        }
+
+        private AudioBookFileInfo FindMainAudioBookFile(List<AudioBookFileInfo> files, string name)
+        {
+            var main = files.Find(x => Path.GetFileNameWithoutExtension(x.Path).Equals(name, StringComparison.OrdinalIgnoreCase));
+            main ??= files.FirstOrDefault(x => Path.GetFileNameWithoutExtension(x.Path).Equals("audiobook", StringComparison.OrdinalIgnoreCase));
+            main ??= files.OrderBy(x => x.Container)
+                .ThenBy(x => x.Path)
+                .First();
+
+            return main;
+        }
     }
 }

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff