فهرست منبع

Merge branch 'master' into studios-images-plugin

# Conflicts:
#	MediaBrowser.Providers/MediaBrowser.Providers.csproj
Cody Robibero 3 سال پیش
والد
کامیت
a04ab6b876
100فایلهای تغییر یافته به همراه1989 افزوده شده و 1368 حذف شده
  1. 1 1
      .ci/azure-pipelines-abi.yml
  2. 0 59
      .ci/azure-pipelines-api-client.yml
  3. 8 1
      .ci/azure-pipelines-main.yml
  4. 39 11
      .ci/azure-pipelines-package.yml
  5. 2 2
      .ci/azure-pipelines-test.yml
  6. 3 5
      .ci/azure-pipelines.yml
  7. 1 0
      .copr
  8. 0 1
      .copr/Makefile
  9. 0 30
      .drone.yml
  10. 0 43
      .github/ISSUE_TEMPLATE/bug_report.md
  11. 106 0
      .github/ISSUE_TEMPLATE/issue report.yml
  12. 7 1
      .github/dependabot.yml
  13. 5 1
      .github/stale.yml
  14. 76 0
      .github/workflows/automation.yml
  15. 2 1
      .github/workflows/codeql-analysis.yml
  16. 119 0
      .github/workflows/commands.yml
  17. 124 0
      .github/workflows/openapi.yml
  18. 4 0
      .gitignore
  19. 2 2
      .vscode/launch.json
  20. 11 1
      CONTRIBUTORS.md
  21. 17 0
      Directory.Build.props
  22. 34 22
      Dockerfile
  23. 29 22
      Dockerfile.arm
  24. 29 20
      Dockerfile.arm64
  25. 3 2
      DvdLib/DvdLib.csproj
  26. 2 1
      DvdLib/Ifo/Dvd.cs
  27. 1 1
      Emby.Dlna/Configuration/DlnaOptions.cs
  28. 0 1
      Emby.Dlna/ConfigurationExtension.cs
  29. 1 1
      Emby.Dlna/ConnectionManager/ControlHandler.cs
  30. 1 1
      Emby.Dlna/ContentDirectory/ContentDirectoryService.cs
  31. 259 485
      Emby.Dlna/ContentDirectory/ControlHandler.cs
  32. 11 8
      Emby.Dlna/ContentDirectory/ServerItem.cs
  33. 0 1
      Emby.Dlna/ContentDirectory/StubType.cs
  34. 2 0
      Emby.Dlna/ControlRequest.cs
  35. 3 1
      Emby.Dlna/ControlResponse.cs
  36. 42 28
      Emby.Dlna/Didl/DidlBuilder.cs
  37. 1 1
      Emby.Dlna/Didl/StringWriterWithEncoding.cs
  38. 0 1
      Emby.Dlna/DlnaConfigurationFactory.cs
  39. 91 119
      Emby.Dlna/DlnaManager.cs
  40. 2 8
      Emby.Dlna/Emby.Dlna.csproj
  41. 3 1
      Emby.Dlna/EventSubscriptionResponse.cs
  42. 9 22
      Emby.Dlna/Eventing/DlnaEventManager.cs
  43. 2 0
      Emby.Dlna/Eventing/EventSubscription.cs
  44. 20 23
      Emby.Dlna/Main/DlnaEntryPoint.cs
  45. 1 1
      Emby.Dlna/MediaReceiverRegistrar/ControlHandler.cs
  46. 0 1
      Emby.Dlna/MediaReceiverRegistrar/MediaReceiverRegistrarXmlBuilder.cs
  47. 126 41
      Emby.Dlna/PlayTo/Device.cs
  48. 2 0
      Emby.Dlna/PlayTo/DeviceInfo.cs
  49. 7 1
      Emby.Dlna/PlayTo/MediaChangedEventArgs.cs
  50. 56 19
      Emby.Dlna/PlayTo/PlayToController.cs
  51. 12 6
      Emby.Dlna/PlayTo/PlayToManager.cs
  52. 5 0
      Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs
  53. 5 0
      Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs
  54. 5 0
      Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs
  55. 2 0
      Emby.Dlna/PlayTo/PlaylistItem.cs
  56. 2 0
      Emby.Dlna/PlayTo/PlaylistItemFactory.cs
  57. 24 14
      Emby.Dlna/PlayTo/SsdpHttpClient.cs
  58. 9 11
      Emby.Dlna/PlayTo/TransportCommands.cs
  59. 0 1
      Emby.Dlna/PlayTo/TransportState.cs
  60. 2 0
      Emby.Dlna/PlayTo/uBaseObject.cs
  61. 3 0
      Emby.Dlna/Profiles/DefaultProfile.cs
  62. 2 2
      Emby.Dlna/Profiles/SonyBravia2010Profile.cs
  63. 2 2
      Emby.Dlna/Profiles/SonyBravia2011Profile.cs
  64. 2 2
      Emby.Dlna/Profiles/SonyBravia2012Profile.cs
  65. 2 2
      Emby.Dlna/Profiles/SonyBravia2013Profile.cs
  66. 2 2
      Emby.Dlna/Profiles/SonyBravia2014Profile.cs
  67. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2010).xml
  68. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2011).xml
  69. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2012).xml
  70. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2013).xml
  71. 2 2
      Emby.Dlna/Profiles/Xml/Sony Bravia (2014).xml
  72. 2 3
      Emby.Dlna/Server/DescriptionXmlBuilder.cs
  73. 6 10
      Emby.Dlna/Service/BaseControlHandler.cs
  74. 4 4
      Emby.Dlna/Service/BaseService.cs
  75. 1 5
      Emby.Dlna/Service/ControlErrorHandler.cs
  76. 4 2
      Emby.Dlna/Ssdp/DeviceDiscovery.cs
  77. 3 3
      Emby.Dlna/Ssdp/SsdpExtensions.cs
  78. 1 8
      Emby.Drawing/Emby.Drawing.csproj
  79. 104 25
      Emby.Drawing/ImageProcessor.cs
  80. 1 1
      Emby.Drawing/NullImageEncoder.cs
  81. 3 3
      Emby.Naming/Audio/AudioFileParser.cs
  82. 7 7
      Emby.Naming/AudioBook/AudioBookInfo.cs
  83. 7 7
      Emby.Naming/AudioBook/AudioBookListResolver.cs
  84. 2 2
      Emby.Naming/AudioBook/AudioBookResolver.cs
  85. 79 24
      Emby.Naming/Common/NamingOptions.cs
  86. 7 13
      Emby.Naming/Emby.Naming.csproj
  87. 6 5
      Emby.Naming/Subtitles/SubtitleParser.cs
  88. 9 5
      Emby.Naming/TV/EpisodeResolver.cs
  89. 1 1
      Emby.Naming/TV/SeasonPathParser.cs
  90. 29 0
      Emby.Naming/TV/SeriesInfo.cs
  91. 60 0
      Emby.Naming/TV/SeriesPathParser.cs
  92. 19 0
      Emby.Naming/TV/SeriesPathParserResult.cs
  93. 49 0
      Emby.Naming/TV/SeriesResolver.cs
  94. 18 11
      Emby.Naming/Video/CleanStringParser.cs
  95. 104 55
      Emby.Naming/Video/ExtraResolver.cs
  96. 17 12
      Emby.Naming/Video/FileStack.cs
  97. 48 0
      Emby.Naming/Video/FileStackRule.cs
  98. 0 53
      Emby.Naming/Video/FlagParser.cs
  99. 36 52
      Emby.Naming/Video/Format3DParser.cs
  100. 9 14
      Emby.Naming/Video/Format3DResult.cs

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

@@ -7,7 +7,7 @@ parameters:
   default: "ubuntu-latest"
   default: "ubuntu-latest"
 - name: DotNetSdkVersion
 - name: DotNetSdkVersion
   type: string
   type: string
-  default: 5.0.100
+  default: 6.0.x
 
 
 jobs:
 jobs:
   - job: CompatibilityCheck
   - job: CompatibilityCheck

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

@@ -1,59 +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'
-  condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
-  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
-    - task: CmdLine@2
-      displayName: 'Build stable typescript axios client'
-      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
-    - task: Npm@1
-      displayName: 'Publish stable typescript axios client'
-      inputs:
-        command: custom
-        customCommand: publish --access public
-        publishRegistry: useExternalRegistry
-        publishEndpoint: 'jellyfin-bot for NPM'
-        workingDir: ./apiclient/generated/typescript/axios

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

@@ -1,7 +1,7 @@
 parameters:
 parameters:
   LinuxImage: 'ubuntu-latest'
   LinuxImage: 'ubuntu-latest'
   RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj'
   RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj'
-  DotNetSdkVersion: 5.0.100
+  DotNetSdkVersion: 6.0.x
 
 
 jobs:
 jobs:
   - job: Build
   - job: Build
@@ -91,3 +91,10 @@ jobs:
         inputs:
         inputs:
           targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/MediaBrowser.Common.dll'
           targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/MediaBrowser.Common.dll'
           artifactName: 'Jellyfin.Common'
           artifactName: 'Jellyfin.Common'
+
+      - task: PublishPipelineArtifact@1
+        displayName: 'Publish Artifact Extensions'
+        condition: and(succeeded(), eq(variables['BuildConfiguration'], 'Release'))
+        inputs:
+          targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/Jellyfin.Extensions.dll'
+          artifactName: 'Jellyfin.Extensions'

+ 39 - 11
.ci/azure-pipelines-package.yml

@@ -39,6 +39,14 @@ jobs:
     vmImage: 'ubuntu-latest'
     vmImage: 'ubuntu-latest'
 
 
   steps:
   steps:
+  - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
+    displayName: Set release version (stable)
+    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
+
+  - script: './bump-version $(JellyfinVersion)'
+    displayName: Bump internal version (stable)
+    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
+
   - script: 'docker build -f deployment/Dockerfile.$(BuildConfiguration) -t jellyfin-server-$(BuildConfiguration) deployment'
   - script: 'docker build -f deployment/Dockerfile.$(BuildConfiguration) -t jellyfin-server-$(BuildConfiguration) deployment'
     displayName: 'Build Dockerfile'
     displayName: 'Build Dockerfile'
 
 
@@ -80,6 +88,14 @@ jobs:
     vmImage: 'ubuntu-latest'
     vmImage: 'ubuntu-latest'
 
 
   steps:
   steps:
+  - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
+    displayName: Set release version (stable)
+    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
+
+  - script: './bump-version $(JellyfinVersion)'
+    displayName: Bump internal version (stable)
+    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
+
   - task: DownloadPipelineArtifact@2
   - task: DownloadPipelineArtifact@2
     displayName: 'Download OpenAPI Spec'
     displayName: 'Download OpenAPI Spec'
     inputs:
     inputs:
@@ -127,6 +143,10 @@ jobs:
     displayName: Set release version (stable)
     displayName: Set release version (stable)
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
 
 
+  - script: './bump-version $(JellyfinVersion)'
+    displayName: Bump internal version (stable)
+    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
+
   - task: Docker@2
   - task: Docker@2
     displayName: 'Push Unstable Image'
     displayName: 'Push Unstable Image'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
     condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
@@ -160,7 +180,6 @@ jobs:
   dependsOn:
   dependsOn:
   - BuildPackage
   - BuildPackage
   - BuildDocker
   - BuildDocker
-  condition: and(succeeded('BuildPackage'), succeeded('BuildDocker'))
 
 
   pool:
   pool:
     vmImage: 'ubuntu-latest'
     vmImage: 'ubuntu-latest'
@@ -182,31 +201,39 @@ jobs:
     inputs:
     inputs:
       sshEndpoint: repository
       sshEndpoint: repository
       runOptions: 'commands'
       runOptions: 'commands'
-      commands: nohup sudo /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) $(Build.SourceBranch) &
 
 
 - job: PublishNuget
 - job: PublishNuget
   displayName: 'Publish NuGet packages'
   displayName: 'Publish NuGet packages'
-  dependsOn:
-  - BuildPackage
-  condition: succeeded('BuildPackage')
 
 
   pool:
   pool:
     vmImage: 'ubuntu-latest'
     vmImage: 'ubuntu-latest'
 
 
+  variables:
+  - name: JellyfinVersion
+    value: $[replace(variables['Build.SourceBranch'],'refs/tags/v','')]
+
   steps:
   steps:
   - task: UseDotNet@2
   - task: UseDotNet@2
-    displayName: 'Use .NET 5.0 sdk'
+    displayName: 'Use .NET 6.0 sdk'
     inputs:
     inputs:
       packageType: 'sdk'
       packageType: 'sdk'
-      version: '5.0.x'
+      version: '6.0.x'
 
 
   - task: DotNetCoreCLI@2
   - task: DotNetCoreCLI@2
     displayName: 'Build Stable Nuget packages'
     displayName: 'Build Stable Nuget packages'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
     inputs:
     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
+        src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
+      custom: 'pack'
+      arguments: -o $(Build.ArtifactStagingDirectory) -p:Version=$(JellyfinVersion)
 
 
   - task: DotNetCoreCLI@2
   - task: DotNetCoreCLI@2
     displayName: 'Build Unstable Nuget packages'
     displayName: 'Build Unstable Nuget packages'
@@ -219,6 +246,7 @@ jobs:
         MediaBrowser.Controller/MediaBrowser.Controller.csproj
         MediaBrowser.Controller/MediaBrowser.Controller.csproj
         MediaBrowser.Model/MediaBrowser.Model.csproj
         MediaBrowser.Model/MediaBrowser.Model.csproj
         Emby.Naming/Emby.Naming.csproj
         Emby.Naming/Emby.Naming.csproj
+        src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
       custom: 'pack'
       custom: 'pack'
       arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory) -p:Stability=Unstable'
       arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory) -p:Stability=Unstable'
 
 
@@ -233,7 +261,7 @@ jobs:
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
     inputs:
     inputs:
       command: 'push'
       command: 'push'
-      packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg;$(Build.ArtifactStagingDirectory)/**/*.snupkg'
+      packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg'
       nuGetFeedType: 'external'
       nuGetFeedType: 'external'
       publishFeedCredentials: 'NugetOrg'
       publishFeedCredentials: 'NugetOrg'
       allowPackageConflicts: true # This ignores an error if the version already exists
       allowPackageConflicts: true # This ignores an error if the version already exists

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

@@ -10,7 +10,7 @@ parameters:
   default: "tests/**/*Tests.csproj"
   default: "tests/**/*Tests.csproj"
 - name: DotNetSdkVersion
 - name: DotNetSdkVersion
   type: string
   type: string
-  default: 5.0.100
+  default: 6.0.x
 
 
 jobs:
 jobs:
   - job: Test
   - job: Test
@@ -94,5 +94,5 @@ jobs:
         displayName: 'Publish OpenAPI Artifact'
         displayName: 'Publish OpenAPI Artifact'
         condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
         condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
         inputs:
         inputs:
-          targetPath: "tests/Jellyfin.Api.Tests/bin/Release/net5.0/openapi.json"
+          targetPath: "tests/Jellyfin.Server.Integration.Tests/bin/Release/net6.0/openapi.json"
           artifactName: 'OpenAPI Spec'
           artifactName: 'OpenAPI Spec'

+ 3 - 5
.ci/azure-pipelines.yml

@@ -5,8 +5,6 @@ variables:
   value: 'tests/**/*Tests.csproj'
   value: 'tests/**/*Tests.csproj'
 - name: RestoreBuildProjects
 - name: RestoreBuildProjects
   value: 'Jellyfin.Server/Jellyfin.Server.csproj'
   value: 'Jellyfin.Server/Jellyfin.Server.csproj'
-- name: DotNetSdkVersion
-  value: 5.0.100
 
 
 pr:
 pr:
   autoCancel: true
   autoCancel: true
@@ -57,10 +55,10 @@ jobs:
         Common:
         Common:
           NugetPackageName: Jellyfin.Common
           NugetPackageName: Jellyfin.Common
           AssemblyFileName: MediaBrowser.Common.dll
           AssemblyFileName: MediaBrowser.Common.dll
+        Extensions:
+          NugetPackageName: Jellyfin.Extensions
+          AssemblyFileName: Jellyfin.Extensions.dll
       LinuxImage: 'ubuntu-latest'
       LinuxImage: 'ubuntu-latest'
 
 
 - ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}:
 - ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}:
   - template: azure-pipelines-package.yml
   - 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

+ 1 - 0
.copr

@@ -0,0 +1 @@
+fedora

+ 0 - 1
.copr/Makefile

@@ -1 +0,0 @@
-../fedora/Makefile

+ 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"
-

+ 0 - 43
.github/ISSUE_TEMPLATE/bug_report.md

@@ -1,43 +0,0 @@
----
-name: Bug report
-about: Create a bug report
-title: ''
-labels: bug
-assignees: ''
-
----
-
-**Describe the bug**
-<!-- A clear and concise description of what the bug is. -->
-
-**System (please complete the following information):**
- - OS: [e.g. Debian, Windows]
- - Virtualization: [e.g. Docker, KVM, LXC]
- - Clients: [Browser, Android, Fire Stick, etc.]
- - 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] 
- - Installed Plugins: [e.g. none, Fanart, Anime, etc.]
- - Reverse Proxy: [e.g. none, nginx, apache, etc.]
- - Base URL: [e.g. none, yes: /example]
- - Networking: [e.g. Host, Bridge/NAT]
- - Storage: [e.g. local, NFS, cloud]
-
-**To Reproduce**
-<!-- Steps to reproduce the behavior: -->
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Expected behavior**
-<!-- A clear and concise description of what you expected to happen. -->
-
-**Logs**
-<!-- Please paste any log errors. -->
-
-**Screenshots**
-<!-- If applicable, add screenshots to help explain your problem. -->
-
-**Additional context**
-<!-- Add any other context about the problem here. -->

+ 106 - 0
.github/ISSUE_TEMPLATE/issue report.yml

@@ -0,0 +1,106 @@
+name: Issue Report
+description: File an issue report
+title: "[Issue]: "
+labels: [bug, triage]
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to fill out this bug report! Please provide as much detail as necessary, most questions may not be applicable to you. If you need real-time help, join us on [Matrix](https://matrix.to/#/#jellyfin-troubleshooting:matrix.org) or [Discord](https://discord.gg/zHBxVSXdBV).
+  - type: textarea
+    id: what-happened
+    attributes:
+      label: Please describe your bug
+      description: Also tell us, what did you expect to happen?
+      placeholder: |
+        The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful.
+
+        This is my issue.
+
+        Steps to Reproduce
+          1. In this environment...
+          2. With this config...
+          3. Run '...'
+          4. See error...
+    validations:
+      required: true
+  - type: dropdown
+    id: version
+    attributes:
+      label: Jellyfin Version
+      description: What version of Jellyfin are you running?
+      options:
+        - 10.7.7
+        - 10.7.z
+        - 10.6.4
+        - Other
+    validations:
+      required: true
+  - type: input
+    id: version-other
+    attributes:
+      label: "if other:"
+      placeholder: Other
+  - type: textarea
+    attributes:
+      label: Environment
+      description: |
+        Examples:
+        - **OS**: [e.g. Debian, Windows]
+        - **Virtualization**: [e.g. Docker, KVM, LXC]
+        - **Clients**: [Browser, Android, Fire Stick, etc.]
+        - **Browser**: [e.g. Firefox 91, Chrome 93, Safari 13]
+        - **FFmpeg Version**: [e.g. 4.3.2-Jellyfin]
+        - **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]
+        - **Networking**: [e.g. Host, Bridge/NAT]
+        - **Storage**: [e.g. local, NFS, cloud]
+      value: |
+        - OS:
+        - Virtualization:
+        - Clients:
+        - Browser:
+        - FFmpeg Version:
+        - Playback Method:
+        - Hardware Acceleration:
+        - Plugins:
+        - Reverse Proxy:
+        - Base URL:
+        - Networking:
+        - Storage:
+      render: markdown
+  - type: textarea
+    id: logs
+    attributes:
+      label: Jellyfin logs
+      description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs.
+      placeholder: For playback issues, browser/client and FFmpeg logs may be more useful.
+      render: shell
+  - type: textarea
+    id: ffmpeg-logs
+    attributes:
+      label: FFmpeg logs
+      description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs.
+      placeholder: It's important to include the specific codec details. If no FFmpeg logs appear, the file was Direct Played and did not use FFmpeg.
+      render: shell
+  - type: textarea
+    id: browserlogs
+    attributes:
+      label: Please attach any browser or client logs here
+      placeholder: Access browser logs by using the F12 to bring up the console. Screenshots are typically easier to read than raw logs. For clients such as Android or iOS, please see our documentation.
+  - type: textarea
+    id: screenshots
+    attributes:
+      label: Please attach any screenshots here
+      placeholder: Images can be pasted directly into the textbox and will be hosted by github.
+  - type: checkboxes
+    id: terms
+    attributes:
+      label: Code of Conduct
+      description: By submitting this issue, you agree to follow our [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct)
+      options:
+        - label: I agree to follow this project's Code of Conduct
+          required: true

+ 7 - 1
.github/dependabot.yml

@@ -6,4 +6,10 @@ updates:
     interval: weekly
     interval: weekly
     time: '12:00'
     time: '12:00'
   open-pull-requests-limit: 10
   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
 # Comment to post when marking an issue as stale. Set to `false` to disable
 markComment: >
 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.
   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.
   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).
   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
 # Comment to post when closing a stale issue. Set to `false` to disable
 closeComment: false
 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 }}

+ 2 - 1
.github/workflows/codeql-analysis.yml

@@ -24,7 +24,8 @@ jobs:
     - name: Setup .NET Core
     - name: Setup .NET Core
       uses: actions/setup-dotnet@v1
       uses: actions/setup-dotnet@v1
       with:
       with:
-        dotnet-version: '5.0.100'
+        dotnet-version: '6.0.x'
+
     - name: Initialize CodeQL
     - name: Initialize CodeQL
       uses: github/codeql-action/init@v1
       uses: github/codeql-action/init@v1
       with:
       with:

+ 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.5
+        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

+ 124 - 0
.github/workflows/openapi.yml

@@ -0,0 +1,124 @@
+name: OpenAPI
+on:
+  push:
+    branches:
+      - master
+  pull_request_target:
+
+jobs:
+  openapi-head:
+    name: OpenAPI - HEAD
+    runs-on: ubuntu-latest
+    permissions: read-all
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.event.pull_request.head.ref }}
+          repository: ${{ github.event.pull_request.head.repo.full_name }}
+      - name: Setup .NET Core
+        uses: actions/setup-dotnet@v1
+        with:
+          dotnet-version: '6.0.x'
+      - name: Generate openapi.json
+        run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
+      - name: Upload openapi.json
+        uses: actions/upload-artifact@v2
+        with:
+          name: openapi-head
+          retention-days: 14
+          if-no-files-found: error
+          path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net6.0/openapi.json
+
+  openapi-base:
+    name: OpenAPI - BASE
+    if: ${{ github.base_ref != '' }}
+    runs-on: ubuntu-latest
+    permissions: read-all
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.base_ref }}
+      - name: Setup .NET Core
+        uses: actions/setup-dotnet@v1
+        with:
+          dotnet-version: '6.0.x'
+      - name: Generate openapi.json
+        run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
+      - name: Upload openapi.json
+        uses: actions/upload-artifact@v2
+        with:
+          name: openapi-base
+          retention-days: 14
+          if-no-files-found: error
+          path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net6.0/openapi.json
+
+  openapi-diff:
+    name: OpenAPI - Difference
+    if: ${{ github.event_name == 'pull_request_target' }}
+    runs-on: ubuntu-latest
+    needs:
+      - openapi-head
+      - openapi-base
+    steps:
+      - name: Download openapi-head
+        uses: actions/download-artifact@v2
+        with:
+          name: openapi-head
+          path: openapi-head
+      - name: Download openapi-base
+        uses: actions/download-artifact@v2
+        with:
+          name: openapi-base
+          path: openapi-base
+      - name: Workaround openapi-diff issue
+        run: |
+          sed -i 's/"allOf"/"oneOf"/g' openapi-head/openapi.json
+          sed -i 's/"allOf"/"oneOf"/g' openapi-base/openapi.json
+      - name: Calculate OpenAPI difference
+        uses: docker://openapitools/openapi-diff
+        continue-on-error: true
+        with:
+          args: --fail-on-changed --markdown openapi-changes.md openapi-base/openapi.json openapi-head/openapi.json
+      - id: read-diff
+        name: Read openapi-diff output
+        run: |
+          body=$(cat openapi-changes.md)
+          body="${body//'%'/'%25'}"
+          body="${body//$'\n'/'%0A'}"
+          body="${body//$'\r'/'%0D'}"
+          echo ::set-output name=body::$body
+      - name: Find difference comment
+        uses: peter-evans/find-comment@v1
+        id: find-comment
+        with:
+          issue-number: ${{ github.event.pull_request.number }}
+          direction: last
+          body-includes: openapi-diff-workflow-comment
+      - name: Reply or edit difference comment (changed)
+        uses: peter-evans/create-or-update-comment@v1.4.5
+        if: ${{ steps.read-diff.outputs.body != '' }}
+        with:
+          issue-number: ${{ github.event.pull_request.number }}
+          comment-id: ${{ steps.find-comment.outputs.comment-id }}
+          edit-mode: replace
+          body: |
+            <!--openapi-diff-workflow-comment-->
+            <details>
+            <summary>Changes in OpenAPI specification found. Expand to see details.</summary>
+
+            ${{ steps.read-diff.outputs.body }}
+
+            </details>
+      - name: Edit difference comment (unchanged)
+        uses: peter-evans/create-or-update-comment@v1.4.5
+        if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
+        with:
+          issue-number: ${{ github.event.pull_request.number }}
+          comment-id: ${{ steps.find-comment.outputs.comment-id }}
+          edit-mode: replace
+          body: |
+            <!--openapi-diff-workflow-comment-->
+
+            No changes to OpenAPI specification found. See history of this comment for previous changes.

+ 4 - 0
.gitignore

@@ -268,6 +268,7 @@ doc/
 # Deployment artifacts
 # Deployment artifacts
 dist
 dist
 *.exe
 *.exe
+*.dll
 
 
 # BenchmarkDotNet artifacts
 # BenchmarkDotNet artifacts
 BenchmarkDotNet.Artifacts
 BenchmarkDotNet.Artifacts
@@ -277,3 +278,6 @@ web/
 web-src.*
 web-src.*
 MediaBrowser.WebDashboard/jellyfin-web
 MediaBrowser.WebDashboard/jellyfin-web
 apiclient/generated
 apiclient/generated
+
+# Omnisharp crash logs
+mono_crash.*.json

+ 2 - 2
.vscode/launch.json

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

+ 11 - 1
CONTRIBUTORS.md

@@ -17,6 +17,7 @@
  - [bugfixin](https://github.com/bugfixin)
  - [bugfixin](https://github.com/bugfixin)
  - [chaosinnovator](https://github.com/chaosinnovator)
  - [chaosinnovator](https://github.com/chaosinnovator)
  - [ckcr4lyf](https://github.com/ckcr4lyf)
  - [ckcr4lyf](https://github.com/ckcr4lyf)
+ - [cocool97](https://github.com/cocool97)
  - [ConfusedPolarBear](https://github.com/ConfusedPolarBear)
  - [ConfusedPolarBear](https://github.com/ConfusedPolarBear)
  - [crankdoofus](https://github.com/crankdoofus)
  - [crankdoofus](https://github.com/crankdoofus)
  - [crobibero](https://github.com/crobibero)
  - [crobibero](https://github.com/crobibero)
@@ -45,10 +46,12 @@
  - [fruhnow](https://github.com/fruhnow)
  - [fruhnow](https://github.com/fruhnow)
  - [geilername](https://github.com/geilername)
  - [geilername](https://github.com/geilername)
  - [gnattu](https://github.com/gnattu)
  - [gnattu](https://github.com/gnattu)
+ - [GodTamIt](https://github.com/GodTamIt)
  - [grafixeyehero](https://github.com/grafixeyehero)
  - [grafixeyehero](https://github.com/grafixeyehero)
  - [h1nk](https://github.com/h1nk)
  - [h1nk](https://github.com/h1nk)
  - [hawken93](https://github.com/hawken93)
  - [hawken93](https://github.com/hawken93)
  - [HelloWorld017](https://github.com/HelloWorld017)
  - [HelloWorld017](https://github.com/HelloWorld017)
+ - [ikomhoog](https://github.com/ikomhoog)
  - [jftuga](https://github.com/jftuga)
  - [jftuga](https://github.com/jftuga)
  - [joern-h](https://github.com/joern-h)
  - [joern-h](https://github.com/joern-h)
  - [joshuaboniface](https://github.com/joshuaboniface)
  - [joshuaboniface](https://github.com/joshuaboniface)
@@ -68,6 +71,7 @@
  - [marius-luca-87](https://github.com/marius-luca-87)
  - [marius-luca-87](https://github.com/marius-luca-87)
  - [mark-monteiro](https://github.com/mark-monteiro)
  - [mark-monteiro](https://github.com/mark-monteiro)
  - [Matt07211](https://github.com/Matt07211)
  - [Matt07211](https://github.com/Matt07211)
+ - [Maxr1998](https://github.com/Maxr1998)
  - [mcarlton00](https://github.com/mcarlton00)
  - [mcarlton00](https://github.com/mcarlton00)
  - [mitchfizz05](https://github.com/mitchfizz05)
  - [mitchfizz05](https://github.com/mitchfizz05)
  - [MrTimscampi](https://github.com/MrTimscampi)
  - [MrTimscampi](https://github.com/MrTimscampi)
@@ -104,10 +108,11 @@
  - [shemanaev](https://github.com/shemanaev)
  - [shemanaev](https://github.com/shemanaev)
  - [skaro13](https://github.com/skaro13)
  - [skaro13](https://github.com/skaro13)
  - [sl1288](https://github.com/sl1288)
  - [sl1288](https://github.com/sl1288)
+ - [Smith00101010](https://github.com/Smith00101010)
  - [sorinyo2004](https://github.com/sorinyo2004)
  - [sorinyo2004](https://github.com/sorinyo2004)
  - [sparky8251](https://github.com/sparky8251)
  - [sparky8251](https://github.com/sparky8251)
  - [spookbits](https://github.com/spookbits)
  - [spookbits](https://github.com/spookbits)
- - [ssenart] (https://github.com/ssenart)
+ - [ssenart](https://github.com/ssenart)
  - [stanionascu](https://github.com/stanionascu)
  - [stanionascu](https://github.com/stanionascu)
  - [stevehayles](https://github.com/stevehayles)
  - [stevehayles](https://github.com/stevehayles)
  - [SuperSandro2000](https://github.com/SuperSandro2000)
  - [SuperSandro2000](https://github.com/SuperSandro2000)
@@ -143,6 +148,9 @@
  - [nielsvanvelzen](https://github.com/nielsvanvelzen)
  - [nielsvanvelzen](https://github.com/nielsvanvelzen)
  - [skyfrk](https://github.com/skyfrk)
  - [skyfrk](https://github.com/skyfrk)
  - [ianjazz246](https://github.com/ianjazz246)
  - [ianjazz246](https://github.com/ianjazz246)
+ - [peterspenler](https://github.com/peterspenler)
+ - [MBR-0001](https://github.com/MBR-0001)
+ - [jonas-resch](https://github.com/jonas-resch)
 
 
 # Emby Contributors
 # Emby Contributors
 
 
@@ -207,3 +215,5 @@
  - [Tim Hobbs](https://github.com/timhobbs)
  - [Tim Hobbs](https://github.com/timhobbs)
  - [SvenVandenbrande](https://github.com/SvenVandenbrande)
  - [SvenVandenbrande](https://github.com/SvenVandenbrande)
  - [olsh](https://github.com/olsh)
  - [olsh](https://github.com/olsh)
+ - [lbenini](https://github.com/lbenini)
+ - [gnuyent](https://github.com/gnuyent)

+ 17 - 0
Directory.Build.props

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

+ 34 - 22
Dockerfile

@@ -1,22 +1,18 @@
-ARG DOTNET_VERSION=5.0
+# DESIGNED FOR BUILDING ON AMD64 ONLY
+#####################################
+# Requires binfm_misc registration
+# https://github.com/multiarch/qemu-user-static#binfmt_misc-register
+ARG DOTNET_VERSION=6.0
 
 
-FROM node:alpine as web-builder
+FROM node:lts-alpine as web-builder
 ARG JELLYFIN_WEB_VERSION=master
 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 - \
  && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
  && cd jellyfin-web-* \
  && cd jellyfin-web-* \
- && yarn install \
+ && npm ci --no-audit --unsafe-perm \
  && mv dist /dist
  && mv dist /dist
 
 
-FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim 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:stable-slim as app
 
 
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 ARG DEBIAN_FRONTEND="noninteractive"
 ARG DEBIAN_FRONTEND="noninteractive"
@@ -25,19 +21,17 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
 # https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support)
 # https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support)
 ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 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
 # 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
+ARG GMMLIB_VERSION=21.2.1
+ARG IGC_VERSION=1.0.8517
+ARG NEO_VERSION=21.35.20826
+ARG LEVEL_ZERO_VERSION=1.2.20826
 
 
 # Install dependencies:
 # Install dependencies:
 # mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding.
 # mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding.
+# curl: healthcheck
 RUN apt-get update \
 RUN apt-get update \
- && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https \
+ && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https curl \
  && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \
  && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \
  && echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \
  && echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \
  && apt-get update \
  && apt-get update \
@@ -68,14 +62,32 @@ RUN apt-get update \
  && chmod 777 /cache /config /media \
  && 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
  && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen
 
 
-ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
+# ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
 ENV LC_ALL en_US.UTF-8
 ENV LC_ALL en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANGUAGE en_US:en
 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
+
+ENV HEALTHCHECK_URL=http://localhost:8096/health
+
+COPY --from=builder /jellyfin /jellyfin
+COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
 EXPOSE 8096
 EXPOSE 8096
 VOLUME /cache /config /media
 VOLUME /cache /config /media
 ENTRYPOINT ["./jellyfin/jellyfin", \
 ENTRYPOINT ["./jellyfin/jellyfin", \
     "--datadir", "/config", \
     "--datadir", "/config", \
     "--cachedir", "/cache", \
     "--cachedir", "/cache", \
     "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"]
     "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"]
+
+HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \
+     CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1

+ 29 - 22
Dockerfile.arm

@@ -1,31 +1,20 @@
-# DESIGNED FOR BUILDING ON AMD64 ONLY
+# DESIGNED FOR BUILDING ON ARM ONLY
 #####################################
 #####################################
 # Requires binfm_misc registration
 # Requires binfm_misc registration
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
-ARG DOTNET_VERSION=5.0
+ARG DOTNET_VERSION=6.0
 
 
 
 
-FROM node:alpine as web-builder
+FROM node:lts-alpine as web-builder
 ARG JELLYFIN_WEB_VERSION=master
 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 - \
  && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
  && cd jellyfin-web-* \
  && cd jellyfin-web-* \
- && yarn install \
+ && npm ci --no-audit --unsafe-perm \
  && mv dist /dist
  && mv dist /dist
 
 
-
-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 multiarch/qemu-user-static:x86_64-arm as qemu
 FROM multiarch/qemu-user-static:x86_64-arm as qemu
-FROM arm32v7/debian:buster-slim
+FROM arm32v7/debian:stable-slim as app
 
 
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 ARG DEBIAN_FRONTEND="noninteractive"
 ARG DEBIAN_FRONTEND="noninteractive"
@@ -35,6 +24,8 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
 ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 
 
 COPY --from=qemu /usr/bin/qemu-arm-static /usr/bin
 COPY --from=qemu /usr/bin/qemu-arm-static /usr/bin
+
+# curl: setup & healthcheck
 RUN apt-get update \
 RUN apt-get update \
  && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg curl && \
  && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg curl && \
  curl -ks https://repo.jellyfin.org/debian/jellyfin_team.gpg.key | apt-key add - && \
  curl -ks https://repo.jellyfin.org/debian/jellyfin_team.gpg.key | apt-key add - && \
@@ -53,7 +44,7 @@ RUN apt-get update \
  vainfo \
  vainfo \
  libva2 \
  libva2 \
  locales \
  locales \
- && apt-get remove curl gnupg -y \
+ && apt-get remove gnupg -y \
  && apt-get clean autoclean -y \
  && apt-get clean autoclean -y \
  && apt-get autoremove -y \
  && apt-get autoremove -y \
  && rm -rf /var/lib/apt/lists/* \
  && rm -rf /var/lib/apt/lists/* \
@@ -61,17 +52,33 @@ RUN apt-get update \
  && chmod 777 /cache /config /media \
  && 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
  && 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 DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
 ENV LC_ALL en_US.UTF-8
 ENV LC_ALL en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANGUAGE en_US:en
 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
+
+ENV HEALTHCHECK_URL=http://localhost:8096/health
+
+COPY --from=builder /jellyfin /jellyfin
+COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
 EXPOSE 8096
 EXPOSE 8096
 VOLUME /cache /config /media
 VOLUME /cache /config /media
 ENTRYPOINT ["./jellyfin/jellyfin", \
 ENTRYPOINT ["./jellyfin/jellyfin", \
     "--datadir", "/config", \
     "--datadir", "/config", \
     "--cachedir", "/cache", \
     "--cachedir", "/cache", \
     "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"]
     "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"]
+
+HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \
+     CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1

+ 29 - 20
Dockerfile.arm64

@@ -1,30 +1,20 @@
-# DESIGNED FOR BUILDING ON AMD64 ONLY
+# DESIGNED FOR BUILDING ON ARM64 ONLY
 #####################################
 #####################################
 # Requires binfm_misc registration
 # Requires binfm_misc registration
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
-ARG DOTNET_VERSION=5.0
+ARG DOTNET_VERSION=6.0
 
 
 
 
-FROM node:alpine as web-builder
+FROM node:lts-alpine as web-builder
 ARG JELLYFIN_WEB_VERSION=master
 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 - \
  && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
  && cd jellyfin-web-* \
  && cd jellyfin-web-* \
- && yarn install \
+ && npm ci --no-audit --unsafe-perm \
  && mv dist /dist
  && mv dist /dist
 
 
-
-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 multiarch/qemu-user-static:x86_64-aarch64 as qemu
 FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu
-FROM arm64v8/debian:buster-slim
+FROM arm64v8/debian:stable-slim as app
 
 
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 ARG DEBIAN_FRONTEND="noninteractive"
 ARG DEBIAN_FRONTEND="noninteractive"
@@ -34,6 +24,8 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
 ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 
 
 COPY --from=qemu /usr/bin/qemu-aarch64-static /usr/bin
 COPY --from=qemu /usr/bin/qemu-aarch64-static /usr/bin
+
+# curl: healcheck
 RUN apt-get update && apt-get install --no-install-recommends --no-install-suggests -y \
 RUN apt-get update && apt-get install --no-install-recommends --no-install-suggests -y \
  ffmpeg \
  ffmpeg \
  libssl-dev \
  libssl-dev \
@@ -43,6 +35,7 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge
  libomxil-bellagio0 \
  libomxil-bellagio0 \
  libomxil-bellagio-bin \
  libomxil-bellagio-bin \
  locales \
  locales \
+ curl \
  && apt-get clean autoclean -y \
  && apt-get clean autoclean -y \
  && apt-get autoremove -y \
  && apt-get autoremove -y \
  && rm -rf /var/lib/apt/lists/* \
  && rm -rf /var/lib/apt/lists/* \
@@ -50,17 +43,33 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge
  && chmod 777 /cache /config /media \
  && 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
  && 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 DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
 ENV LC_ALL en_US.UTF-8
 ENV LC_ALL en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANGUAGE en_US:en
 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
+
+ENV HEALTHCHECK_URL=http://localhost:8096/health
+
+COPY --from=builder /jellyfin /jellyfin
+COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
 EXPOSE 8096
 EXPOSE 8096
 VOLUME /cache /config /media
 VOLUME /cache /config /media
 ENTRYPOINT ["./jellyfin/jellyfin", \
 ENTRYPOINT ["./jellyfin/jellyfin", \
     "--datadir", "/config", \
     "--datadir", "/config", \
     "--cachedir", "/cache", \
     "--cachedir", "/cache", \
     "--ffmpeg", "/usr/bin/ffmpeg"]
     "--ffmpeg", "/usr/bin/ffmpeg"]
+
+HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \
+     CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1

+ 3 - 2
DvdLib/DvdLib.csproj

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

+ 2 - 1
DvdLib/Ifo/Dvd.cs

@@ -2,6 +2,7 @@
 
 
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
+using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 
 
@@ -76,7 +77,7 @@ namespace DvdLib.Ifo
 
 
         private void ReadVTS(ushort vtsNum, IReadOnlyList<FileInfo> allFiles)
         private void ReadVTS(ushort vtsNum, IReadOnlyList<FileInfo> allFiles)
         {
         {
-            var filename = string.Format("VTS_{0:00}_0.IFO", vtsNum);
+            var filename = string.Format(CultureInfo.InvariantCulture, "VTS_{0:00}_0.IFO", vtsNum);
 
 
             var vtsPath = allFiles.FirstOrDefault(i => string.Equals(i.Name, filename, StringComparison.OrdinalIgnoreCase)) ??
             var vtsPath = allFiles.FirstOrDefault(i => string.Equals(i.Name, filename, StringComparison.OrdinalIgnoreCase)) ??
                 allFiles.FirstOrDefault(i => string.Equals(i.Name, Path.ChangeExtension(filename, ".bup"), StringComparison.OrdinalIgnoreCase));
                 allFiles.FirstOrDefault(i => string.Equals(i.Name, Path.ChangeExtension(filename, ".bup"), StringComparison.OrdinalIgnoreCase));

+ 1 - 1
Emby.Dlna/Configuration/DlnaOptions.cs

@@ -72,7 +72,7 @@ namespace Emby.Dlna.Configuration
         /// <summary>
         /// <summary>
         /// Gets or sets the default user account that the dlna server uses.
         /// Gets or sets the default user account that the dlna server uses.
         /// </summary>
         /// </summary>
-        public string DefaultUserId { get; set; }
+        public string? DefaultUserId { get; set; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets a value indicating whether playTo device profiles should be created.
         /// Gets or sets a value indicating whether playTo device profiles should be created.

+ 0 - 1
Emby.Dlna/ConfigurationExtension.cs

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

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

@@ -31,7 +31,7 @@ namespace Emby.Dlna.ConnectionManager
         }
         }
 
 
         /// <inheritdoc />
         /// <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))
             if (string.Equals(methodName, "GetProtocolInfo", StringComparison.OrdinalIgnoreCase))
             {
             {

+ 1 - 1
Emby.Dlna/ContentDirectory/ContentDirectoryService.cs

@@ -138,7 +138,7 @@ namespace Emby.Dlna.ContentDirectory
         /// </summary>
         /// </summary>
         /// <param name="profile">The <see cref="DeviceProfile"/>.</param>
         /// <param name="profile">The <see cref="DeviceProfile"/>.</param>
         /// <returns>The <see cref="User"/>.</returns>
         /// <returns>The <see cref="User"/>.</returns>
-        private User GetUser(DeviceProfile profile)
+        private User? GetUser(DeviceProfile profile)
         {
         {
             if (!string.IsNullOrEmpty(profile.UserId))
             if (!string.IsNullOrEmpty(profile.UserId))
             {
             {

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 259 - 485
Emby.Dlna/ContentDirectory/ControlHandler.cs


+ 11 - 8
Emby.Dlna/ContentDirectory/ServerItem.cs

@@ -1,5 +1,3 @@
-#pragma warning disable CS1591
-
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
 
 
 namespace Emby.Dlna.ContentDirectory
 namespace Emby.Dlna.ContentDirectory
@@ -13,24 +11,29 @@ namespace Emby.Dlna.ContentDirectory
         /// Initializes a new instance of the <see cref="ServerItem"/> class.
         /// Initializes a new instance of the <see cref="ServerItem"/> class.
         /// </summary>
         /// </summary>
         /// <param name="item">The <see cref="BaseItem"/>.</param>
         /// <param name="item">The <see cref="BaseItem"/>.</param>
-        public ServerItem(BaseItem item)
+        /// <param name="stubType">The stub type.</param>
+        public ServerItem(BaseItem item, StubType? stubType)
         {
         {
             Item = item;
             Item = item;
 
 
-            if (item is IItemByName && !(item is Folder))
+            if (stubType.HasValue)
+            {
+                StubType = stubType;
+            }
+            else if (item is IItemByName and not Folder)
             {
             {
                 StubType = Dlna.ContentDirectory.StubType.Folder;
                 StubType = Dlna.ContentDirectory.StubType.Folder;
             }
             }
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Gets or sets the underlying base item.
+        /// Gets the underlying base item.
         /// </summary>
         /// </summary>
-        public BaseItem Item { get; set; }
+        public BaseItem Item { get; }
 
 
         /// <summary>
         /// <summary>
-        /// Gets or sets the DLNA item type.
+        /// Gets the DLNA item type.
         /// </summary>
         /// </summary>
-        public StubType? StubType { get; set; }
+        public StubType? StubType { get; }
     }
     }
 }
 }

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

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

+ 2 - 0
Emby.Dlna/ControlRequest.cs

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

+ 3 - 1
Emby.Dlna/ControlResponse.cs

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

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

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
@@ -39,8 +41,6 @@ namespace Emby.Dlna.Didl
         private const string NsUpnp = "urn:schemas-upnp-org:metadata-1-0/upnp/";
         private const string NsUpnp = "urn:schemas-upnp-org:metadata-1-0/upnp/";
         private const string NsDlna = "urn:schemas-dlna-org:metadata-1-0/";
         private const string NsDlna = "urn:schemas-dlna-org:metadata-1-0/";
 
 
-        private readonly CultureInfo _usCulture = new CultureInfo("en-US");
-
         private readonly DeviceProfile _profile;
         private readonly DeviceProfile _profile;
         private readonly IImageProcessor _imageProcessor;
         private readonly IImageProcessor _imageProcessor;
         private readonly string _serverAddress;
         private readonly string _serverAddress;
@@ -96,6 +96,7 @@ namespace Emby.Dlna.Didl
 
 
             using (StringWriter builder = new StringWriterWithEncoding(Encoding.UTF8))
             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))
                 using (var writer = XmlWriter.Create(builder, settings))
                 {
                 {
                     // writer.WriteStartDocument();
                     // writer.WriteStartDocument();
@@ -207,7 +208,8 @@ namespace Emby.Dlna.Didl
             var targetWidth = streamInfo.TargetWidth;
             var targetWidth = streamInfo.TargetWidth;
             var targetHeight = streamInfo.TargetHeight;
             var targetHeight = streamInfo.TargetHeight;
 
 
-            var contentFeatureList = new ContentFeatureBuilder(_profile).BuildVideoHeader(
+            var contentFeatureList = ContentFeatureBuilder.BuildVideoHeader(
+                _profile,
                 streamInfo.Container,
                 streamInfo.Container,
                 streamInfo.TargetVideoCodec.FirstOrDefault(),
                 streamInfo.TargetVideoCodec.FirstOrDefault(),
                 streamInfo.TargetAudioCodec.FirstOrDefault(),
                 streamInfo.TargetAudioCodec.FirstOrDefault(),
@@ -313,7 +315,7 @@ namespace Emby.Dlna.Didl
 
 
             if (mediaSource.RunTimeTicks.HasValue)
             if (mediaSource.RunTimeTicks.HasValue)
             {
             {
-                writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", _usCulture));
+                writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture));
             }
             }
 
 
             if (filter.Contains("res@size"))
             if (filter.Contains("res@size"))
@@ -324,7 +326,7 @@ namespace Emby.Dlna.Didl
 
 
                     if (size.HasValue)
                     if (size.HasValue)
                     {
                     {
-                        writer.WriteAttributeString("size", size.Value.ToString(_usCulture));
+                        writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture));
                     }
                     }
                 }
                 }
             }
             }
@@ -338,7 +340,7 @@ namespace Emby.Dlna.Didl
 
 
             if (targetChannels.HasValue)
             if (targetChannels.HasValue)
             {
             {
-                writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(_usCulture));
+                writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture));
             }
             }
 
 
             if (filter.Contains("res@resolution"))
             if (filter.Contains("res@resolution"))
@@ -357,12 +359,12 @@ namespace Emby.Dlna.Didl
 
 
             if (targetSampleRate.HasValue)
             if (targetSampleRate.HasValue)
             {
             {
-                writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(_usCulture));
+                writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture));
             }
             }
 
 
             if (totalBitrate.HasValue)
             if (totalBitrate.HasValue)
             {
             {
-                writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(_usCulture));
+                writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(CultureInfo.InvariantCulture));
             }
             }
 
 
             var mediaProfile = _profile.GetVideoMediaProfile(
             var mediaProfile = _profile.GetVideoMediaProfile(
@@ -548,7 +550,7 @@ namespace Emby.Dlna.Didl
 
 
             if (mediaSource.RunTimeTicks.HasValue)
             if (mediaSource.RunTimeTicks.HasValue)
             {
             {
-                writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", _usCulture));
+                writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture));
             }
             }
 
 
             if (filter.Contains("res@size"))
             if (filter.Contains("res@size"))
@@ -559,7 +561,7 @@ namespace Emby.Dlna.Didl
 
 
                     if (size.HasValue)
                     if (size.HasValue)
                     {
                     {
-                        writer.WriteAttributeString("size", size.Value.ToString(_usCulture));
+                        writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture));
                     }
                     }
                 }
                 }
             }
             }
@@ -571,17 +573,17 @@ namespace Emby.Dlna.Didl
 
 
             if (targetChannels.HasValue)
             if (targetChannels.HasValue)
             {
             {
-                writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(_usCulture));
+                writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture));
             }
             }
 
 
             if (targetSampleRate.HasValue)
             if (targetSampleRate.HasValue)
             {
             {
-                writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(_usCulture));
+                writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture));
             }
             }
 
 
             if (targetAudioBitrate.HasValue)
             if (targetAudioBitrate.HasValue)
             {
             {
-                writer.WriteAttributeString("bitrate", targetAudioBitrate.Value.ToString(_usCulture));
+                writer.WriteAttributeString("bitrate", targetAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
             }
             }
 
 
             var mediaProfile = _profile.GetAudioMediaProfile(
             var mediaProfile = _profile.GetAudioMediaProfile(
@@ -598,7 +600,8 @@ namespace Emby.Dlna.Didl
                 ? MimeTypes.GetMimeType(filename)
                 ? MimeTypes.GetMimeType(filename)
                 : mediaProfile.MimeType;
                 : mediaProfile.MimeType;
 
 
-            var contentFeatures = new ContentFeatureBuilder(_profile).BuildAudioHeader(
+            var contentFeatures = ContentFeatureBuilder.BuildAudioHeader(
+                _profile,
                 streamInfo.Container,
                 streamInfo.Container,
                 streamInfo.TargetAudioCodec.FirstOrDefault(),
                 streamInfo.TargetAudioCodec.FirstOrDefault(),
                 targetAudioBitrate,
                 targetAudioBitrate,
@@ -634,7 +637,7 @@ namespace Emby.Dlna.Didl
 
 
             writer.WriteAttributeString("restricted", "1");
             writer.WriteAttributeString("restricted", "1");
             writer.WriteAttributeString("searchable", "1");
             writer.WriteAttributeString("searchable", "1");
-            writer.WriteAttributeString("childCount", childCount.ToString(_usCulture));
+            writer.WriteAttributeString("childCount", childCount.ToString(CultureInfo.InvariantCulture));
 
 
             var clientId = GetClientId(folder, stubType);
             var clientId = GetClientId(folder, stubType);
 
 
@@ -726,7 +729,7 @@ namespace Emby.Dlna.Didl
             {
             {
                 if (item.PremiereDate.HasValue)
                 if (item.PremiereDate.HasValue)
                 {
                 {
-                    AddValue(writer, "dc", "date", item.PremiereDate.Value.ToString("o", CultureInfo.InvariantCulture), NsDc);
+                    AddValue(writer, "dc", "date", item.PremiereDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), NsDc);
                 }
                 }
             }
             }
 
 
@@ -743,7 +746,7 @@ namespace Emby.Dlna.Didl
                 AddValue(writer, "upnp", "publisher", studio, NsUpnp);
                 AddValue(writer, "upnp", "publisher", studio, NsUpnp);
             }
             }
 
 
-            if (!(item is Folder))
+            if (item is not Folder)
             {
             {
                 if (filter.Contains("dc:description"))
                 if (filter.Contains("dc:description"))
                 {
                 {
@@ -926,11 +929,11 @@ namespace Emby.Dlna.Didl
 
 
             if (item.IndexNumber.HasValue)
             if (item.IndexNumber.HasValue)
             {
             {
-                AddValue(writer, "upnp", "originalTrackNumber", item.IndexNumber.Value.ToString(_usCulture), NsUpnp);
+                AddValue(writer, "upnp", "originalTrackNumber", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), NsUpnp);
 
 
                 if (item is Episode)
                 if (item is Episode)
                 {
                 {
-                    AddValue(writer, "upnp", "episodeNumber", item.IndexNumber.Value.ToString(_usCulture), NsUpnp);
+                    AddValue(writer, "upnp", "episodeNumber", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), NsUpnp);
                 }
                 }
             }
             }
         }
         }
@@ -973,15 +976,28 @@ namespace Emby.Dlna.Didl
                 return;
                 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.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();
             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);
             writer.WriteElementString("upnp", "icon", NsUpnp, iconUrlInfo.url);
 
 
             if (!_profile.EnableAlbumArtInDidl)
             if (!_profile.EnableAlbumArtInDidl)
@@ -1032,8 +1048,7 @@ namespace Emby.Dlna.Didl
             var width = albumartUrlInfo.width ?? maxWidth;
             var width = albumartUrlInfo.width ?? maxWidth;
             var height = albumartUrlInfo.height ?? maxHeight;
             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(
             writer.WriteAttributeString(
                 "protocolInfo",
                 "protocolInfo",
@@ -1205,8 +1220,7 @@ namespace Emby.Dlna.Didl
 
 
             if (width.HasValue && height.HasValue)
             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;
                 width = newSize.Width;
                 height = newSize.Height;
                 height = newSize.Height;

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

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

+ 0 - 1
Emby.Dlna/DlnaConfigurationFactory.cs

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

+ 91 - 119
Emby.Dlna/DlnaManager.cs

@@ -1,20 +1,18 @@
 #pragma warning disable CS1591
 #pragma warning disable CS1591
-
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 using System.Reflection;
 using System.Reflection;
-using System.Text;
 using System.Text.Json;
 using System.Text.Json;
 using System.Text.RegularExpressions;
 using System.Text.RegularExpressions;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Emby.Dlna.Profiles;
 using Emby.Dlna.Profiles;
 using Emby.Dlna.Server;
 using Emby.Dlna.Server;
+using Jellyfin.Extensions.Json;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Json;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Drawing;
@@ -36,7 +34,7 @@ namespace Emby.Dlna
         private readonly ILogger<DlnaManager> _logger;
         private readonly ILogger<DlnaManager> _logger;
         private readonly IServerApplicationHost _appHost;
         private readonly IServerApplicationHost _appHost;
         private static readonly Assembly _assembly = typeof(DlnaManager).Assembly;
         private static readonly Assembly _assembly = typeof(DlnaManager).Assembly;
-        private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions();
+        private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
 
 
         private readonly Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>> _profiles = new Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>>(StringComparer.Ordinal);
         private readonly Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>> _profiles = new Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>>(StringComparer.Ordinal);
 
 
@@ -94,12 +92,14 @@ namespace Emby.Dlna
             }
             }
         }
         }
 
 
+        /// <inheritdoc />
         public DeviceProfile GetDefaultProfile()
         public DeviceProfile GetDefaultProfile()
         {
         {
             return new DefaultProfile();
             return new DefaultProfile();
         }
         }
 
 
-        public DeviceProfile GetProfile(DeviceIdentification deviceInfo)
+        /// <inheritdoc />
+        public DeviceProfile? GetProfile(DeviceIdentification deviceInfo)
         {
         {
             if (deviceInfo == null)
             if (deviceInfo == null)
             {
             {
@@ -109,109 +109,57 @@ namespace Emby.Dlna
             var profile = GetProfiles()
             var profile = GetProfiles()
                 .FirstOrDefault(i => i.Identification != null && IsMatch(deviceInfo, i.Identification));
                 .FirstOrDefault(i => i.Identification != null && IsMatch(deviceInfo, i.Identification));
 
 
-            if (profile != null)
+            if (profile == null)
             {
             {
-                _logger.LogDebug("Found matching device profile: {0}", profile.Name);
+                _logger.LogInformation("No matching device profile found. The default will need to be used. \n{@Profile}", deviceInfo);
             }
             }
             else
             else
             {
             {
-                LogUnmatchedProfile(deviceInfo);
+                _logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name);
             }
             }
 
 
             return profile;
             return profile;
         }
         }
 
 
-        private void LogUnmatchedProfile(DeviceIdentification profile)
+        /// <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)
         {
         {
-            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);
-
-            _logger.LogInformation(builder.ToString());
+            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);
         }
         }
 
 
-        private bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo)
+        private bool IsRegexOrSubstringMatch(string input, string pattern)
         {
         {
-            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 (string.IsNullOrEmpty(pattern))
             {
             {
-                if (deviceInfo.ModelNumber == null || !IsRegexOrSubstringMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber))
-                {
-                    return false;
-                }
-            }
-
-            if (!string.IsNullOrEmpty(profileInfo.ModelUrl))
-            {
-                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
             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)
             catch (ArgumentException ex)
             {
             {
@@ -220,7 +168,8 @@ namespace Emby.Dlna
             }
             }
         }
         }
 
 
-        public DeviceProfile GetProfile(IHeaderDictionary headers)
+        /// <inheritdoc />
+        public DeviceProfile? GetProfile(IHeaderDictionary headers)
         {
         {
             if (headers == null)
             if (headers == null)
             {
             {
@@ -228,15 +177,13 @@ namespace Emby.Dlna
             }
             }
 
 
             var profile = GetProfiles().FirstOrDefault(i => i.Identification != null && IsMatch(headers, i.Identification));
             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
             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;
             return profile;
@@ -286,19 +233,19 @@ namespace Emby.Dlna
                 return xmlFies
                 return xmlFies
                     .Select(i => ParseProfileFile(i, type))
                     .Select(i => ParseProfileFile(i, type))
                     .Where(i => i != null)
                     .Where(i => i != null)
-                    .ToList();
+                    .ToList()!; // We just filtered out all the nulls
             }
             }
             catch (IOException)
             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)
             lock (_profiles)
             {
             {
-                if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile> profileTuple))
+                if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile>? profileTuple))
                 {
                 {
                     return profileTuple.Item2;
                     return profileTuple.Item2;
                 }
                 }
@@ -326,14 +273,20 @@ namespace Emby.Dlna
             }
             }
         }
         }
 
 
-        public DeviceProfile GetProfile(string id)
+        /// <inheritdoc />
+        public DeviceProfile? GetProfile(string id)
         {
         {
             if (string.IsNullOrEmpty(id))
             if (string.IsNullOrEmpty(id))
             {
             {
                 throw new ArgumentNullException(nameof(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);
             return ParseProfileFile(info.Path, info.Info.Type);
         }
         }
@@ -350,6 +303,7 @@ namespace Emby.Dlna
             }
             }
         }
         }
 
 
+        /// <inheritdoc />
         public IEnumerable<DeviceProfileInfo> GetProfileInfos()
         public IEnumerable<DeviceProfileInfo> GetProfileInfos()
         {
         {
             return GetProfileInfosInternal().Select(i => i.Info);
             return GetProfileInfosInternal().Select(i => i.Info);
@@ -357,17 +311,14 @@ namespace Emby.Dlna
 
 
         private InternalProfileInfo GetInternalProfileInfo(FileSystemMetadata file, DeviceProfileType type)
         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),
                     Id = file.FullName.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture),
                     Name = _fileSystem.GetFileNameWithoutExtension(file),
                     Name = _fileSystem.GetFileNameWithoutExtension(file),
                     Type = type
                     Type = type
-                }
-            };
+                },
+                file.FullName);
         }
         }
 
 
         private async Task ExtractSystemProfilesAsync()
         private async Task ExtractSystemProfilesAsync()
@@ -387,15 +338,20 @@ namespace Emby.Dlna
                     systemProfilesPath,
                     systemProfilesPath,
                     Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length));
                     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 length = stream.Length;
                     var fileInfo = _fileSystem.GetFileInfo(path);
                     var fileInfo = _fileSystem.GetFileInfo(path);
 
 
-                    if (!fileInfo.Exists || fileInfo.Length != stream.Length)
+                    if (!fileInfo.Exists || fileInfo.Length != length)
                     {
                     {
                         Directory.CreateDirectory(systemProfilesPath);
                         Directory.CreateDirectory(systemProfilesPath);
 
 
-                        using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
+                        var fileOptions = AsyncFile.WriteOptions;
+                        fileOptions.Mode = FileMode.Create;
+                        fileOptions.PreallocationSize = length;
+                        using (var fileStream = new FileStream(path, fileOptions))
                         {
                         {
                             await stream.CopyToAsync(fileStream).ConfigureAwait(false);
                             await stream.CopyToAsync(fileStream).ConfigureAwait(false);
                         }
                         }
@@ -407,6 +363,7 @@ namespace Emby.Dlna
             Directory.CreateDirectory(UserProfilesPath);
             Directory.CreateDirectory(UserProfilesPath);
         }
         }
 
 
+        /// <inheritdoc />
         public void DeleteProfile(string id)
         public void DeleteProfile(string id)
         {
         {
             var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase));
             var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase));
@@ -424,6 +381,7 @@ namespace Emby.Dlna
             }
             }
         }
         }
 
 
+        /// <inheritdoc />
         public void CreateProfile(DeviceProfile profile)
         public void CreateProfile(DeviceProfile profile)
         {
         {
             profile = ReserializeProfile(profile);
             profile = ReserializeProfile(profile);
@@ -439,7 +397,8 @@ namespace Emby.Dlna
             SaveProfile(profile, path, DeviceProfileType.User);
             SaveProfile(profile, path, DeviceProfileType.User);
         }
         }
 
 
-        public void UpdateProfile(DeviceProfile profile)
+        /// <inheritdoc />
+        public void UpdateProfile(string profileId, DeviceProfile profile)
         {
         {
             profile = ReserializeProfile(profile);
             profile = ReserializeProfile(profile);
 
 
@@ -453,7 +412,7 @@ namespace Emby.Dlna
                 throw new ArgumentException("Profile is missing Name");
                 throw new ArgumentException("Profile is missing Name");
             }
             }
 
 
-            var current = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, profile.Id, StringComparison.OrdinalIgnoreCase));
+            var current = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, profileId, StringComparison.OrdinalIgnoreCase));
 
 
             var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml";
             var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml";
             var path = Path.Combine(UserProfilesPath, newFilename);
             var path = Path.Combine(UserProfilesPath, newFilename);
@@ -497,9 +456,11 @@ namespace Emby.Dlna
 
 
             var json = JsonSerializer.Serialize(profile, _jsonOptions);
             var json = JsonSerializer.Serialize(profile, _jsonOptions);
 
 
-            return JsonSerializer.Deserialize<DeviceProfile>(json, _jsonOptions);
+            // 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)
         public string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress)
         {
         {
             var profile = GetDefaultProfile();
             var profile = GetDefaultProfile();
@@ -509,26 +470,37 @@ namespace Emby.Dlna
             return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml();
             return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml();
         }
         }
 
 
-        public ImageStream GetIcon(string filename)
+        /// <inheritdoc />
+        public ImageStream? GetIcon(string filename)
         {
         {
             var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
             var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
                 ? ImageFormat.Png
                 ? ImageFormat.Png
                 : ImageFormat.Jpg;
                 : ImageFormat.Jpg;
 
 
             var resource = GetType().Namespace + ".Images." + filename.ToLowerInvariant();
             var resource = GetType().Namespace + ".Images." + filename.ToLowerInvariant();
+            var stream = _assembly.GetManifestResourceStream(resource);
+            if (stream == null)
+            {
+                return null;
+            }
 
 
-            return new ImageStream
+            return new ImageStream(stream)
             {
             {
-                Format = format,
-                Stream = _assembly.GetManifestResourceStream(resource)
+                Format = format
             };
             };
         }
         }
 
 
         private class InternalProfileInfo
         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 +525,7 @@ namespace Emby.Dlna
 
 
         private void DumpProfiles()
         private void DumpProfiles()
         {
         {
-            DeviceProfile[] list = new []
+            DeviceProfile[] list = new[]
             {
             {
                 new SamsungSmartTvProfile(),
                 new SamsungSmartTvProfile(),
                 new XboxOneProfile(),
                 new XboxOneProfile(),

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

@@ -17,24 +17,18 @@
   </ItemGroup>
   </ItemGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <!-- Code Analyzers-->
   <!-- Code Analyzers-->
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
   <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="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
     <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
     <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
   </ItemGroup>
   </ItemGroup>
 
 
-  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
-  </PropertyGroup>
-
   <ItemGroup>
   <ItemGroup>
     <EmbeddedResource Include="Images\logo120.jpg" />
     <EmbeddedResource Include="Images\logo120.jpg" />
     <EmbeddedResource Include="Images\logo120.png" />
     <EmbeddedResource Include="Images\logo120.png" />
@@ -78,7 +72,7 @@
   </ItemGroup>
   </ItemGroup>
 
 
   <ItemGroup>
   <ItemGroup>
-    <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
   </ItemGroup>
   </ItemGroup>
 
 
 </Project>
 </Project>

+ 3 - 1
Emby.Dlna/EventSubscriptionResponse.cs

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

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

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
@@ -9,6 +11,7 @@ using System.Net.Http;
 using System.Net.Mime;
 using System.Net.Mime;
 using System.Text;
 using System.Text;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
+using Jellyfin.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
@@ -23,8 +26,6 @@ namespace Emby.Dlna.Eventing
         private readonly ILogger _logger;
         private readonly ILogger _logger;
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IHttpClientFactory _httpClientFactory;
 
 
-        private readonly CultureInfo _usCulture = new CultureInfo("en-US");
-
         public DlnaEventManager(ILogger logger, IHttpClientFactory httpClientFactory)
         public DlnaEventManager(ILogger logger, IHttpClientFactory httpClientFactory)
         {
         {
             _httpClientFactory = httpClientFactory;
             _httpClientFactory = httpClientFactory;
@@ -49,11 +50,7 @@ namespace Emby.Dlna.Eventing
                 return GetEventSubscriptionResponse(subscriptionId, requestedTimeoutString, timeoutSeconds);
                 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)
         public EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl)
@@ -84,9 +81,7 @@ namespace Emby.Dlna.Eventing
             if (!string.IsNullOrEmpty(header))
             if (!string.IsNullOrEmpty(header))
             {
             {
                 // Starts with SECOND-
                 // Starts with SECOND-
-                header = header.Split('-')[^1];
-
-                if (int.TryParse(header, NumberStyles.Integer, _usCulture, out var val))
+                if (int.TryParse(header.AsSpan().RightPart('-'), NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
                 {
                 {
                     return val;
                     return val;
                 }
                 }
@@ -101,23 +96,15 @@ namespace Emby.Dlna.Eventing
 
 
             _subscriptions.TryRemove(subscriptionId, out _);
             _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)
         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["SID"] = subscriptionId;
-            response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(_usCulture)) : requestedTimeoutString;
+            response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(CultureInfo.InvariantCulture)) : requestedTimeoutString;
 
 
             return response;
             return response;
         }
         }
@@ -174,7 +161,7 @@ namespace Emby.Dlna.Eventing
             options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType);
             options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType);
             options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange");
             options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange");
             options.Headers.TryAddWithoutValidation("SID", subscription.Id);
             options.Headers.TryAddWithoutValidation("SID", subscription.Id);
-            options.Headers.TryAddWithoutValidation("SEQ", subscription.TriggerCount.ToString(_usCulture));
+            options.Headers.TryAddWithoutValidation("SEQ", subscription.TriggerCount.ToString(CultureInfo.InvariantCulture));
 
 
             try
             try
             {
             {

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

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

+ 20 - 23
Emby.Dlna/Main/DlnaEntryPoint.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
@@ -5,7 +7,6 @@ using System.Globalization;
 using System.Linq;
 using System.Linq;
 using System.Net.Http;
 using System.Net.Http;
 using System.Net.Sockets;
 using System.Net.Sockets;
-using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Emby.Dlna.PlayTo;
 using Emby.Dlna.PlayTo;
 using Emby.Dlna.Ssdp;
 using Emby.Dlna.Ssdp;
@@ -26,11 +27,9 @@ using MediaBrowser.Controller.TV;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Net;
-using MediaBrowser.Model.System;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 using Rssdp;
 using Rssdp;
 using Rssdp.Infrastructure;
 using Rssdp.Infrastructure;
-using OperatingSystem = MediaBrowser.Common.System.OperatingSystem;
 
 
 namespace Emby.Dlna.Main
 namespace Emby.Dlna.Main
 {
 {
@@ -53,7 +52,6 @@ namespace Emby.Dlna.Main
         private readonly ISocketFactory _socketFactory;
         private readonly ISocketFactory _socketFactory;
         private readonly INetworkManager _networkManager;
         private readonly INetworkManager _networkManager;
         private readonly object _syncLock = new object();
         private readonly object _syncLock = new object();
-        private readonly NetworkConfiguration _netConfig;
         private readonly bool _disabled;
         private readonly bool _disabled;
 
 
         private PlayToManager _manager;
         private PlayToManager _manager;
@@ -126,9 +124,10 @@ namespace Emby.Dlna.Main
                 config);
                 config);
             Current = this;
             Current = this;
 
 
-            _netConfig = config.GetConfiguration<NetworkConfiguration>("network");
-            _disabled = appHost.ListenWithHttps && _netConfig.RequireHttps;
-            if (_disabled)
+            var netConfig = config.GetConfiguration<NetworkConfiguration>(NetworkConfigurationStore.StoreKey);
+            _disabled = appHost.ListenWithHttps && netConfig.RequireHttps;
+
+            if (_disabled && _config.GetDlnaConfiguration().EnableServer)
             {
             {
                 _logger.LogError("The DLNA specification does not support HTTPS.");
                 _logger.LogError("The DLNA specification does not support HTTPS.");
             }
             }
@@ -202,8 +201,8 @@ namespace Emby.Dlna.Main
             {
             {
                 if (_communicationsServer == null)
                 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)
                     _communicationsServer = new SsdpCommunicationsServer(_socketFactory, _networkManager, _logger, enableMultiSocketBinding)
                     {
                     {
@@ -219,16 +218,14 @@ namespace Emby.Dlna.Main
             }
             }
         }
         }
 
 
-        private void LogMessage(string msg)
-        {
-            _logger.LogDebug(msg);
-        }
-
         private void StartDeviceDiscovery(ISsdpCommunicationsServer communicationsServer)
         private void StartDeviceDiscovery(ISsdpCommunicationsServer communicationsServer)
         {
         {
             try
             try
             {
             {
-                ((DeviceDiscovery)_deviceDiscovery).Start(communicationsServer);
+                if (communicationsServer != null)
+                {
+                    ((DeviceDiscovery)_deviceDiscovery).Start(communicationsServer);
+                }
             }
             }
             catch (Exception ex)
             catch (Exception ex)
             {
             {
@@ -263,9 +260,13 @@ namespace Emby.Dlna.Main
 
 
             try
             try
             {
             {
-                _publisher = new SsdpDevicePublisher(_communicationsServer, _networkManager, OperatingSystem.Name, Environment.OSVersion.VersionString, _config.GetDlnaConfiguration().SendOnlyMatchedHost)
+                _publisher = new SsdpDevicePublisher(
+                    _communicationsServer,
+                    MediaBrowser.Common.System.OperatingSystem.Name,
+                    Environment.OSVersion.VersionString,
+                    _config.GetDlnaConfiguration().SendOnlyMatchedHost)
                 {
                 {
-                    LogFunction = LogMessage,
+                    LogFunction = (msg) => _logger.LogDebug("{Msg}", msg),
                     SupportPnpRootDevice = false
                     SupportPnpRootDevice = false
                 };
                 };
 
 
@@ -310,12 +311,9 @@ namespace Emby.Dlna.Main
 
 
                 var fullService = "urn:schemas-upnp-org:device:MediaServer:1";
                 var fullService = "urn:schemas-upnp-org:device:MediaServer:1";
 
 
-                _logger.LogInformation("Registering publisher for {0} on {1}", fullService, address);
+                _logger.LogInformation("Registering publisher for {ResourceName} on {DeviceAddress}", fullService, address);
 
 
-                var uri = new UriBuilder(_appHost.GetSmartApiUrl(address.Address) + descriptorUri);
-                // DLNA will only work over http, so we must reset to http:// : {port}
-                uri.Scheme = "http://";
-                uri.Port = _netConfig.HttpServerPortNumber;
+                var uri = new UriBuilder(_appHost.GetApiUrlForLocalAccess(false) + descriptorUri);
 
 
                 var device = new SsdpRootDevice
                 var device = new SsdpRootDevice
                 {
                 {
@@ -401,7 +399,6 @@ namespace Emby.Dlna.Main
                         _imageProcessor,
                         _imageProcessor,
                         _deviceDiscovery,
                         _deviceDiscovery,
                         _httpClientFactory,
                         _httpClientFactory,
-                        _config,
                         _userDataManager,
                         _userDataManager,
                         _localization,
                         _localization,
                         _mediaSourceManager,
                         _mediaSourceManager,

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

@@ -24,7 +24,7 @@ namespace Emby.Dlna.MediaReceiverRegistrar
         }
         }
 
 
         /// <inheritdoc />
         /// <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))
             if (string.Equals(methodName, "IsAuthorized", StringComparison.OrdinalIgnoreCase))
             {
             {

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

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

+ 126 - 41
Emby.Dlna/PlayTo/Device.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
@@ -18,8 +20,6 @@ namespace Emby.Dlna.PlayTo
 {
 {
     public class Device : IDisposable
     public class Device : IDisposable
     {
     {
-        private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
-
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IHttpClientFactory _httpClientFactory;
 
 
         private readonly ILogger _logger;
         private readonly ILogger _logger;
@@ -219,7 +219,7 @@ namespace Emby.Dlna.PlayTo
         {
         {
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
             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)
             if (command == null)
             {
             {
                 return false;
                 return false;
@@ -235,7 +235,13 @@ namespace Emby.Dlna.PlayTo
             _logger.LogDebug("Setting mute");
             _logger.LogDebug("Setting mute");
             var value = mute ? 1 : 0;
             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);
                 .ConfigureAwait(false);
 
 
             IsMuted = mute;
             IsMuted = mute;
@@ -253,7 +259,7 @@ namespace Emby.Dlna.PlayTo
         {
         {
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
             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)
             if (command == null)
             {
             {
                 return;
                 return;
@@ -270,7 +276,13 @@ namespace Emby.Dlna.PlayTo
             // Remote control will perform better
             // Remote control will perform better
             Volume = value;
             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);
                 .ConfigureAwait(false);
         }
         }
 
 
@@ -278,7 +290,7 @@ namespace Emby.Dlna.PlayTo
         {
         {
             var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
             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)
             if (command == null)
             {
             {
                 return;
                 return;
@@ -291,7 +303,13 @@ namespace Emby.Dlna.PlayTo
                 throw new InvalidOperationException("Unable to find service");
                 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);
                 .ConfigureAwait(false);
 
 
             RestartTimer(true);
             RestartTimer(true);
@@ -305,7 +323,7 @@ namespace Emby.Dlna.PlayTo
 
 
             _logger.LogDebug("{0} - SetAvTransport Uri: {1} DlnaHeaders: {2}", Properties.Name, url, header);
             _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)
             if (command == null)
             {
             {
                 return;
                 return;
@@ -325,14 +343,21 @@ namespace Emby.Dlna.PlayTo
             }
             }
 
 
             var post = avCommands.BuildPost(command, service.ServiceType, url, dictionary);
             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);
                 .ConfigureAwait(false);
 
 
-            await Task.Delay(50).ConfigureAwait(false);
+            await Task.Delay(50, cancellationToken).ConfigureAwait(false);
 
 
             try
             try
             {
             {
-                await SetPlay(avCommands, CancellationToken.None).ConfigureAwait(false);
+                await SetPlay(avCommands, cancellationToken).ConfigureAwait(false);
             }
             }
             catch
             catch
             {
             {
@@ -343,6 +368,42 @@ namespace Emby.Dlna.PlayTo
             RestartTimer(true);
             RestartTimer(true);
         }
         }
 
 
+        /*
+         * 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)
         private static string CreateDidlMeta(string value)
         {
         {
             if (string.IsNullOrEmpty(value))
             if (string.IsNullOrEmpty(value))
@@ -378,6 +439,10 @@ namespace Emby.Dlna.PlayTo
         public async Task SetPlay(CancellationToken cancellationToken)
         public async Task SetPlay(CancellationToken cancellationToken)
         {
         {
             var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
             var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
+            if (avCommands == null)
+            {
+                return;
+            }
 
 
             await SetPlay(avCommands, cancellationToken).ConfigureAwait(false);
             await SetPlay(avCommands, cancellationToken).ConfigureAwait(false);
 
 
@@ -388,7 +453,7 @@ namespace Emby.Dlna.PlayTo
         {
         {
             var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
             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)
             if (command == null)
             {
             {
                 return;
                 return;
@@ -396,7 +461,13 @@ namespace Emby.Dlna.PlayTo
 
 
             var service = GetAvTransportService();
             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);
                 .ConfigureAwait(false);
 
 
             RestartTimer(true);
             RestartTimer(true);
@@ -406,7 +477,7 @@ namespace Emby.Dlna.PlayTo
         {
         {
             var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
             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)
             if (command == null)
             {
             {
                 return;
                 return;
@@ -414,7 +485,13 @@ namespace Emby.Dlna.PlayTo
 
 
             var service = GetAvTransportService();
             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);
                 .ConfigureAwait(false);
 
 
             TransportState = TransportState.Paused;
             TransportState = TransportState.Paused;
@@ -528,7 +605,7 @@ namespace Emby.Dlna.PlayTo
 
 
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
             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)
             if (command == null)
             {
             {
                 return;
                 return;
@@ -561,7 +638,7 @@ namespace Emby.Dlna.PlayTo
                 return;
                 return;
             }
             }
 
 
-            Volume = int.Parse(volumeValue, UsCulture);
+            Volume = int.Parse(volumeValue, CultureInfo.InvariantCulture);
 
 
             if (Volume > 0)
             if (Volume > 0)
             {
             {
@@ -578,7 +655,7 @@ namespace Emby.Dlna.PlayTo
 
 
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
             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)
             if (command == null)
             {
             {
                 return;
                 return;
@@ -665,6 +742,10 @@ namespace Emby.Dlna.PlayTo
             }
             }
 
 
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
+            if (rendererCommands == null)
+            {
+                return null;
+            }
 
 
             var result = await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(
             var result = await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(
                 Properties.BaseUrl,
                 Properties.BaseUrl,
@@ -733,6 +814,11 @@ namespace Emby.Dlna.PlayTo
 
 
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
             var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false);
 
 
+            if (rendererCommands == null)
+            {
+                return (false, null);
+            }
+
             var result = await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(
             var result = await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(
                 Properties.BaseUrl,
                 Properties.BaseUrl,
                 service,
                 service,
@@ -754,7 +840,7 @@ namespace Emby.Dlna.PlayTo
             if (!string.IsNullOrWhiteSpace(duration)
             if (!string.IsNullOrWhiteSpace(duration)
                 && !string.Equals(duration, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase))
                 && !string.Equals(duration, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase))
             {
             {
-                Duration = TimeSpan.Parse(duration, UsCulture);
+                Duration = TimeSpan.Parse(duration, CultureInfo.InvariantCulture);
             }
             }
             else
             else
             {
             {
@@ -766,7 +852,7 @@ namespace Emby.Dlna.PlayTo
 
 
             if (!string.IsNullOrWhiteSpace(position) && !string.Equals(position, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase))
             if (!string.IsNullOrWhiteSpace(position) && !string.Equals(position, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase))
             {
             {
-                Position = TimeSpan.Parse(position, UsCulture);
+                Position = TimeSpan.Parse(position, CultureInfo.InvariantCulture);
             }
             }
 
 
             var track = result.Document.Descendants("TrackMetaData").FirstOrDefault();
             var track = result.Document.Descendants("TrackMetaData").FirstOrDefault();
@@ -914,6 +1000,10 @@ namespace Emby.Dlna.PlayTo
             var httpClient = new SsdpHttpClient(_httpClientFactory);
             var httpClient = new SsdpHttpClient(_httpClientFactory);
 
 
             var document = await httpClient.GetDataAsync(url, cancellationToken).ConfigureAwait(false);
             var document = await httpClient.GetDataAsync(url, cancellationToken).ConfigureAwait(false);
+            if (document == null)
+            {
+                return null;
+            }
 
 
             AvCommands = TransportCommands.Create(document);
             AvCommands = TransportCommands.Create(document);
             return AvCommands;
             return AvCommands;
@@ -942,6 +1032,10 @@ namespace Emby.Dlna.PlayTo
             var httpClient = new SsdpHttpClient(_httpClientFactory);
             var httpClient = new SsdpHttpClient(_httpClientFactory);
             _logger.LogDebug("Dlna Device.GetRenderingProtocolAsync");
             _logger.LogDebug("Dlna Device.GetRenderingProtocolAsync");
             var document = await httpClient.GetDataAsync(url, cancellationToken).ConfigureAwait(false);
             var document = await httpClient.GetDataAsync(url, cancellationToken).ConfigureAwait(false);
+            if (document == null)
+            {
+                return null;
+            }
 
 
             RendererCommands = TransportCommands.Create(document);
             RendererCommands = TransportCommands.Create(document);
             return RendererCommands;
             return RendererCommands;
@@ -973,6 +1067,10 @@ namespace Emby.Dlna.PlayTo
             var ssdpHttpClient = new SsdpHttpClient(httpClientFactory);
             var ssdpHttpClient = new SsdpHttpClient(httpClientFactory);
 
 
             var document = await ssdpHttpClient.GetDataAsync(url.ToString(), cancellationToken).ConfigureAwait(false);
             var document = await ssdpHttpClient.GetDataAsync(url.ToString(), cancellationToken).ConfigureAwait(false);
+            if (document == null)
+            {
+                return null;
+            }
 
 
             var friendlyNames = new List<string>();
             var friendlyNames = new List<string>();
 
 
@@ -990,7 +1088,7 @@ namespace Emby.Dlna.PlayTo
 
 
             var deviceProperties = new DeviceInfo()
             var deviceProperties = new DeviceInfo()
             {
             {
-                Name = string.Join(" ", friendlyNames),
+                Name = string.Join(' ', friendlyNames),
                 BaseUrl = string.Format(CultureInfo.InvariantCulture, "http://{0}:{1}", url.Host, url.Port)
                 BaseUrl = string.Format(CultureInfo.InvariantCulture, "http://{0}:{1}", url.Host, url.Port)
             };
             };
 
 
@@ -1094,8 +1192,8 @@ namespace Emby.Dlna.PlayTo
             var depth = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("depth"));
             var depth = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("depth"));
             var url = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("url"));
             var url = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("url"));
 
 
-            var widthValue = int.Parse(width, NumberStyles.Integer, UsCulture);
-            var heightValue = int.Parse(height, NumberStyles.Integer, UsCulture);
+            var widthValue = int.Parse(width, NumberStyles.Integer, CultureInfo.InvariantCulture);
+            var heightValue = int.Parse(height, NumberStyles.Integer, CultureInfo.InvariantCulture);
 
 
             return new DeviceIcon
             return new DeviceIcon
             {
             {
@@ -1160,10 +1258,7 @@ namespace Emby.Dlna.PlayTo
                 return;
                 return;
             }
             }
 
 
-            PlaybackStart?.Invoke(this, new PlaybackStartEventArgs
-            {
-                MediaInfo = mediaInfo
-            });
+            PlaybackStart?.Invoke(this, new PlaybackStartEventArgs(mediaInfo));
         }
         }
 
 
         private void OnPlaybackProgress(UBaseObject mediaInfo)
         private void OnPlaybackProgress(UBaseObject mediaInfo)
@@ -1173,27 +1268,17 @@ namespace Emby.Dlna.PlayTo
                 return;
                 return;
             }
             }
 
 
-            PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs
-            {
-                MediaInfo = mediaInfo
-            });
+            PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs(mediaInfo));
         }
         }
 
 
         private void OnPlaybackStop(UBaseObject 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)
         private void OnMediaChanged(UBaseObject old, UBaseObject newMedia)
         {
         {
-            MediaChanged?.Invoke(this, new MediaChangedEventArgs
-            {
-                OldMediaInfo = old,
-                NewMediaInfo = newMedia
-            });
+            MediaChanged?.Invoke(this, new MediaChangedEventArgs(old, newMedia));
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />

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

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System.Collections.Generic;
 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;
 using System;
 
 
@@ -6,6 +6,12 @@ namespace Emby.Dlna.PlayTo
 {
 {
     public class MediaChangedEventArgs : EventArgs
     public class MediaChangedEventArgs : EventArgs
     {
     {
+        public MediaChangedEventArgs(UBaseObject oldMediaInfo, UBaseObject newMediaInfo)
+        {
+            OldMediaInfo = oldMediaInfo;
+            NewMediaInfo = newMediaInfo;
+        }
+
         public UBaseObject OldMediaInfo { get; set; }
         public UBaseObject OldMediaInfo { get; set; }
 
 
         public UBaseObject NewMediaInfo { get; set; }
         public UBaseObject NewMediaInfo { get; set; }

+ 56 - 19
Emby.Dlna/PlayTo/PlayToController.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
@@ -28,8 +30,6 @@ namespace Emby.Dlna.PlayTo
 {
 {
     public class PlayToController : ISessionController, IDisposable
     public class PlayToController : ISessionController, IDisposable
     {
     {
-        private static readonly CultureInfo _usCulture = CultureInfo.ReadOnly(new CultureInfo("en-US"));
-
         private readonly SessionInfo _session;
         private readonly SessionInfo _session;
         private readonly ISessionManager _sessionManager;
         private readonly ISessionManager _sessionManager;
         private readonly ILibraryManager _libraryManager;
         private readonly ILibraryManager _libraryManager;
@@ -102,6 +102,22 @@ namespace Emby.Dlna.PlayTo
             _deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft;
             _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()
         private void OnDeviceUnavailable()
         {
         {
             try
             try
@@ -132,7 +148,7 @@ namespace Emby.Dlna.PlayTo
 
 
         private async void OnDeviceMediaChanged(object sender, MediaChangedEventArgs e)
         private async void OnDeviceMediaChanged(object sender, MediaChangedEventArgs e)
         {
         {
-            if (_disposed)
+            if (_disposed || string.IsNullOrEmpty(e.OldMediaInfo.Url))
             {
             {
                 return;
                 return;
             }
             }
@@ -156,6 +172,15 @@ namespace Emby.Dlna.PlayTo
                 var newItemProgress = GetProgressInfo(streamInfo);
                 var newItemProgress = GetProgressInfo(streamInfo);
 
 
                 await _sessionManager.OnPlaybackStart(newItemProgress).ConfigureAwait(false);
                 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)
             catch (Exception ex)
             {
             {
@@ -425,6 +450,11 @@ namespace Emby.Dlna.PlayTo
                     var newItem = CreatePlaylistItem(info.Item, user, newPosition, info.MediaSourceId, info.AudioStreamIndex, info.SubtitleStreamIndex);
                     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);
                     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;
                     return;
                 }
                 }
 
 
@@ -499,8 +529,8 @@ namespace Emby.Dlna.PlayTo
 
 
             if (streamInfo.MediaType == DlnaProfileType.Audio)
             if (streamInfo.MediaType == DlnaProfileType.Audio)
             {
             {
-                return new ContentFeatureBuilder(profile)
-                    .BuildAudioHeader(
+                return ContentFeatureBuilder.BuildAudioHeader(
+                        profile,
                         streamInfo.Container,
                         streamInfo.Container,
                         streamInfo.TargetAudioCodec.FirstOrDefault(),
                         streamInfo.TargetAudioCodec.FirstOrDefault(),
                         streamInfo.TargetAudioBitrate,
                         streamInfo.TargetAudioBitrate,
@@ -514,8 +544,8 @@ namespace Emby.Dlna.PlayTo
 
 
             if (streamInfo.MediaType == DlnaProfileType.Video)
             if (streamInfo.MediaType == DlnaProfileType.Video)
             {
             {
-                var list = new ContentFeatureBuilder(profile)
-                    .BuildVideoHeader(
+                var list = ContentFeatureBuilder.BuildVideoHeader(
+                        profile,
                         streamInfo.Container,
                         streamInfo.Container,
                         streamInfo.TargetVideoCodec.FirstOrDefault(),
                         streamInfo.TargetVideoCodec.FirstOrDefault(),
                         streamInfo.TargetAudioCodec.FirstOrDefault(),
                         streamInfo.TargetAudioCodec.FirstOrDefault(),
@@ -623,6 +653,9 @@ namespace Emby.Dlna.PlayTo
 
 
             await _device.SetAvTransport(currentitem.StreamUrl, GetDlnaHeaders(currentitem), currentitem.Didl, cancellationToken).ConfigureAwait(false);
             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;
             var streamInfo = currentitem.StreamInfo;
             if (streamInfo.StartPositionTicks > 0 && EnableClientSideSeek(streamInfo))
             if (streamInfo.StartPositionTicks > 0 && EnableClientSideSeek(streamInfo))
             {
             {
@@ -681,7 +714,7 @@ namespace Emby.Dlna.PlayTo
                 case GeneralCommandType.SetAudioStreamIndex:
                 case GeneralCommandType.SetAudioStreamIndex:
                     if (command.Arguments.TryGetValue("Index", out string index))
                     if (command.Arguments.TryGetValue("Index", out string index))
                     {
                     {
-                        if (int.TryParse(index, NumberStyles.Integer, _usCulture, out var val))
+                        if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
                         {
                         {
                             return SetAudioStreamIndex(val);
                             return SetAudioStreamIndex(val);
                         }
                         }
@@ -693,7 +726,7 @@ namespace Emby.Dlna.PlayTo
                 case GeneralCommandType.SetSubtitleStreamIndex:
                 case GeneralCommandType.SetSubtitleStreamIndex:
                     if (command.Arguments.TryGetValue("Index", out index))
                     if (command.Arguments.TryGetValue("Index", out index))
                     {
                     {
-                        if (int.TryParse(index, NumberStyles.Integer, _usCulture, out var val))
+                        if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
                         {
                         {
                             return SetSubtitleStreamIndex(val);
                             return SetSubtitleStreamIndex(val);
                         }
                         }
@@ -705,7 +738,7 @@ namespace Emby.Dlna.PlayTo
                 case GeneralCommandType.SetVolume:
                 case GeneralCommandType.SetVolume:
                     if (command.Arguments.TryGetValue("Volume", out string vol))
                     if (command.Arguments.TryGetValue("Volume", out string vol))
                     {
                     {
-                        if (int.TryParse(vol, NumberStyles.Integer, _usCulture, out var volume))
+                        if (int.TryParse(vol, NumberStyles.Integer, CultureInfo.InvariantCulture, out var volume))
                         {
                         {
                             return _device.SetVolume(volume, cancellationToken);
                             return _device.SetVolume(volume, cancellationToken);
                         }
                         }
@@ -736,6 +769,10 @@ namespace Emby.Dlna.PlayTo
 
 
                     await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
                     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))
                     if (EnableClientSideSeek(newItem.StreamInfo))
                     {
                     {
                         await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
                         await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
@@ -761,6 +798,10 @@ namespace Emby.Dlna.PlayTo
 
 
                     await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
                     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)
                     if (EnableClientSideSeek(newItem.StreamInfo) && newPosition > 0)
                     {
                     {
                         await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
                         await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
@@ -777,7 +818,7 @@ namespace Emby.Dlna.PlayTo
             var currentWait = 0;
             var currentWait = 0;
             while (_device.TransportState != TransportState.Playing && currentWait < MaxWait)
             while (_device.TransportState != TransportState.Playing && currentWait < MaxWait)
             {
             {
-                await Task.Delay(Interval).ConfigureAwait(false);
+                await Task.Delay(Interval, cancellationToken).ConfigureAwait(false);
                 currentWait += Interval;
                 currentWait += Interval;
             }
             }
 
 
@@ -896,16 +937,16 @@ namespace Emby.Dlna.PlayTo
 
 
                 var parts = url.Split('/');
                 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];
                     var part = parts[i];
 
 
                     if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase) ||
                     if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase) ||
                         string.Equals(part, "videos", 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;
                         }
                         }
                     }
                     }
                 }
                 }
@@ -943,11 +984,7 @@ namespace Emby.Dlna.PlayTo
                 request.DeviceId = values.GetValueOrDefault("DeviceId");
                 request.DeviceId = values.GetValueOrDefault("DeviceId");
                 request.MediaSourceId = values.GetValueOrDefault("MediaSourceId");
                 request.MediaSourceId = values.GetValueOrDefault("MediaSourceId");
                 request.LiveStreamId = values.GetValueOrDefault("LiveStreamId");
                 request.LiveStreamId = values.GetValueOrDefault("LiveStreamId");
-
-                // Be careful, IsDirectStream==true by default (Static != false or not in query).
-                // See initialization of StreamingRequestDto in AudioController.GetAudioStream() method : Static = @static ?? true.
-                request.IsDirectStream = !string.Equals("false", values.GetValueOrDefault("Static"), StringComparison.OrdinalIgnoreCase);
-
+                request.IsDirectStream = string.Equals("true", values.GetValueOrDefault("Static"), StringComparison.OrdinalIgnoreCase);
                 request.AudioStreamIndex = GetIntValue(values, "AudioStreamIndex");
                 request.AudioStreamIndex = GetIntValue(values, "AudioStreamIndex");
                 request.SubtitleStreamIndex = GetIntValue(values, "SubtitleStreamIndex");
                 request.SubtitleStreamIndex = GetIntValue(values, "SubtitleStreamIndex");
                 request.StartPositionTicks = GetLongValue(values, "StartPositionTicks");
                 request.StartPositionTicks = GetLongValue(values, "StartPositionTicks");

+ 12 - 6
Emby.Dlna/PlayTo/PlayToManager.cs

@@ -1,3 +1,5 @@
+#nullable disable
+
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
@@ -9,7 +11,6 @@ using System.Threading.Tasks;
 using Jellyfin.Data.Events;
 using Jellyfin.Data.Events;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
-using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
@@ -33,7 +34,6 @@ namespace Emby.Dlna.PlayTo
         private readonly IServerApplicationHost _appHost;
         private readonly IServerApplicationHost _appHost;
         private readonly IImageProcessor _imageProcessor;
         private readonly IImageProcessor _imageProcessor;
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IHttpClientFactory _httpClientFactory;
-        private readonly IServerConfigurationManager _config;
         private readonly IUserDataManager _userDataManager;
         private readonly IUserDataManager _userDataManager;
         private readonly ILocalizationManager _localization;
         private readonly ILocalizationManager _localization;
 
 
@@ -45,7 +45,7 @@ namespace Emby.Dlna.PlayTo
         private SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1);
         private SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1);
         private CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource();
         private CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource();
 
 
-        public PlayToManager(ILogger logger, ISessionManager sessionManager, ILibraryManager libraryManager, IUserManager userManager, IDlnaManager dlnaManager, IServerApplicationHost appHost, IImageProcessor imageProcessor, IDeviceDiscovery deviceDiscovery, IHttpClientFactory httpClientFactory, IServerConfigurationManager config, IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder)
+        public PlayToManager(ILogger logger, ISessionManager sessionManager, ILibraryManager libraryManager, IUserManager userManager, IDlnaManager dlnaManager, IServerApplicationHost appHost, IImageProcessor imageProcessor, IDeviceDiscovery deviceDiscovery, IHttpClientFactory httpClientFactory, IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder)
         {
         {
             _logger = logger;
             _logger = logger;
             _sessionManager = sessionManager;
             _sessionManager = sessionManager;
@@ -56,7 +56,6 @@ namespace Emby.Dlna.PlayTo
             _imageProcessor = imageProcessor;
             _imageProcessor = imageProcessor;
             _deviceDiscovery = deviceDiscovery;
             _deviceDiscovery = deviceDiscovery;
             _httpClientFactory = httpClientFactory;
             _httpClientFactory = httpClientFactory;
-            _config = config;
             _userDataManager = userDataManager;
             _userDataManager = userDataManager;
             _localization = localization;
             _localization = localization;
             _mediaSourceManager = mediaSourceManager;
             _mediaSourceManager = mediaSourceManager;
@@ -171,19 +170,26 @@ namespace Emby.Dlna.PlayTo
                 uuid = uri.ToString().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);
+            var sessionInfo = await _sessionManager
+                .LogSessionActivity("DLNA", _appHost.ApplicationVersionString, uuid, null, uri.OriginalString, null)
+                .ConfigureAwait(false);
 
 
             var controller = sessionInfo.SessionControllers.OfType<PlayToController>().FirstOrDefault();
             var controller = sessionInfo.SessionControllers.OfType<PlayToController>().FirstOrDefault();
 
 
             if (controller == null)
             if (controller == null)
             {
             {
                 var device = await Device.CreateuPnpDeviceAsync(uri, _httpClientFactory, _logger, cancellationToken).ConfigureAwait(false);
                 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;
                 string deviceName = device.Properties.Name;
 
 
                 _sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName);
                 _sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName);
 
 
-                string serverAddress = _appHost.GetSmartApiUrl(info.LocalIpAddress);
+                string serverAddress = _appHost.GetSmartApiUrl(info.RemoteIpAddress);
 
 
                 controller = new PlayToController(
                 controller = new PlayToController(
                     sessionInfo,
                     sessionInfo,

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

@@ -6,6 +6,11 @@ namespace Emby.Dlna.PlayTo
 {
 {
     public class PlaybackProgressEventArgs : EventArgs
     public class PlaybackProgressEventArgs : EventArgs
     {
     {
+        public PlaybackProgressEventArgs(UBaseObject mediaInfo)
+        {
+            MediaInfo = mediaInfo;
+        }
+
         public UBaseObject MediaInfo { get; set; }
         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 class PlaybackStartEventArgs : EventArgs
     {
     {
+        public PlaybackStartEventArgs(UBaseObject mediaInfo)
+        {
+            MediaInfo = mediaInfo;
+        }
+
         public UBaseObject MediaInfo { get; set; }
         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 class PlaybackStoppedEventArgs : EventArgs
     {
     {
+        public PlaybackStoppedEventArgs(UBaseObject mediaInfo)
+        {
+            MediaInfo = mediaInfo;
+        }
+
         public UBaseObject MediaInfo { get; set; }
         public UBaseObject MediaInfo { get; set; }
     }
     }
 }
 }

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

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

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

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

+ 24 - 14
Emby.Dlna/PlayTo/SsdpHttpClient.cs

@@ -1,8 +1,9 @@
+#nullable disable
+
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
 using System.Globalization;
 using System.Globalization;
-using System.IO;
 using System.Net.Http;
 using System.Net.Http;
 using System.Net.Mime;
 using System.Net.Mime;
 using System.Text;
 using System.Text;
@@ -19,8 +20,6 @@ namespace Emby.Dlna.PlayTo
         private const string USERAGENT = "Microsoft-Windows/6.2 UPnP/1.0 Microsoft-DLNA DLNADOC/1.50";
         private const string USERAGENT = "Microsoft-Windows/6.2 UPnP/1.0 Microsoft-DLNA DLNADOC/1.50";
         private const string FriendlyName = "Jellyfin";
         private const string FriendlyName = "Jellyfin";
 
 
-        private readonly CultureInfo _usCulture = new CultureInfo("en-US");
-
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IHttpClientFactory _httpClientFactory;
 
 
         public SsdpHttpClient(IHttpClientFactory httpClientFactory)
         public SsdpHttpClient(IHttpClientFactory httpClientFactory)
@@ -44,11 +43,13 @@ namespace Emby.Dlna.PlayTo
                     header,
                     header,
                     cancellationToken)
                     cancellationToken)
                 .ConfigureAwait(false);
                 .ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
+
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-            using var reader = new StreamReader(stream, Encoding.UTF8);
-            return XDocument.Parse(
-                await reader.ReadToEndAsync().ConfigureAwait(false),
-                LoadOptions.PreserveWhitespace);
+            return await XDocument.LoadAsync(
+                stream,
+                LoadOptions.None,
+                cancellationToken).ConfigureAwait(false);
         }
         }
 
 
         private static string NormalizeServiceUrl(string baseUrl, string serviceUrl)
         private static string NormalizeServiceUrl(string baseUrl, string serviceUrl)
@@ -77,14 +78,15 @@ namespace Emby.Dlna.PlayTo
         {
         {
             using var options = new HttpRequestMessage(new HttpMethod("SUBSCRIBE"), url);
             using var options = new HttpRequestMessage(new HttpMethod("SUBSCRIBE"), url);
             options.Headers.UserAgent.ParseAdd(USERAGENT);
             options.Headers.UserAgent.ParseAdd(USERAGENT);
-            options.Headers.TryAddWithoutValidation("HOST", ip + ":" + port.ToString(_usCulture));
-            options.Headers.TryAddWithoutValidation("CALLBACK", "<" + localIp + ":" + eventport.ToString(_usCulture) + ">");
+            options.Headers.TryAddWithoutValidation("HOST", ip + ":" + port.ToString(CultureInfo.InvariantCulture));
+            options.Headers.TryAddWithoutValidation("CALLBACK", "<" + localIp + ":" + eventport.ToString(CultureInfo.InvariantCulture) + ">");
             options.Headers.TryAddWithoutValidation("NT", "upnp:event");
             options.Headers.TryAddWithoutValidation("NT", "upnp:event");
-            options.Headers.TryAddWithoutValidation("TIMEOUT", "Second-" + timeOut.ToString(_usCulture));
+            options.Headers.TryAddWithoutValidation("TIMEOUT", "Second-" + timeOut.ToString(CultureInfo.InvariantCulture));
 
 
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
                 .SendAsync(options, HttpCompletionOption.ResponseHeadersRead)
                 .SendAsync(options, HttpCompletionOption.ResponseHeadersRead)
                 .ConfigureAwait(false);
                 .ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
         }
         }
 
 
         public async Task<XDocument> GetDataAsync(string url, CancellationToken cancellationToken)
         public async Task<XDocument> GetDataAsync(string url, CancellationToken cancellationToken)
@@ -93,11 +95,19 @@ namespace Emby.Dlna.PlayTo
             options.Headers.UserAgent.ParseAdd(USERAGENT);
             options.Headers.UserAgent.ParseAdd(USERAGENT);
             options.Headers.TryAddWithoutValidation("FriendlyName.DLNA.ORG", FriendlyName);
             options.Headers.TryAddWithoutValidation("FriendlyName.DLNA.ORG", FriendlyName);
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-            using var reader = new StreamReader(stream, Encoding.UTF8);
-            return XDocument.Parse(
-                await reader.ReadToEndAsync().ConfigureAwait(false),
-                LoadOptions.PreserveWhitespace);
+            try
+            {
+                return await XDocument.LoadAsync(
+                    stream,
+                    LoadOptions.None,
+                    cancellationToken).ConfigureAwait(false);
+            }
+            catch
+            {
+                return null;
+            }
         }
         }
 
 
         private async Task<HttpResponseMessage> PostSoapDataAsync(
         private async Task<HttpResponseMessage> PostSoapDataAsync(

+ 9 - 11
Emby.Dlna/PlayTo/TransportCommands.cs

@@ -13,12 +13,10 @@ namespace Emby.Dlna.PlayTo
     public class TransportCommands
     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 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)
         public static TransportCommands Create(XDocument document)
         {
         {
@@ -48,7 +46,7 @@ namespace Emby.Dlna.PlayTo
         {
         {
             var serviceAction = new ServiceAction
             var serviceAction = new ServiceAction
             {
             {
-                Name = container.GetValue(UPnpNamespaces.Svc + "name"),
+                Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty,
             };
             };
 
 
             var argumentList = serviceAction.ArgumentList;
             var argumentList = serviceAction.ArgumentList;
@@ -70,9 +68,9 @@ namespace Emby.Dlna.PlayTo
 
 
             return new Argument
             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
             };
             };
         }
         }
 
 
@@ -91,8 +89,8 @@ namespace Emby.Dlna.PlayTo
 
 
             return new StateVariable
             return new StateVariable
             {
             {
-                Name = container.GetValue(UPnpNamespaces.Svc + "name"),
-                DataType = container.GetValue(UPnpNamespaces.Svc + "dataType"),
+                Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty,
+                DataType = container.GetValue(UPnpNamespaces.Svc + "dataType") ?? string.Empty,
                 AllowedValues = allowedValues
                 AllowedValues = allowedValues
             };
             };
         }
         }
@@ -168,7 +166,7 @@ namespace Emby.Dlna.PlayTo
             return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamesapce, stateString);
             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));
             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 CS1591
-#pragma warning disable SA1602
 
 
 namespace Emby.Dlna.PlayTo
 namespace Emby.Dlna.PlayTo
 {
 {

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

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

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

@@ -1,5 +1,7 @@
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
+using System;
+using System.Globalization;
 using System.Linq;
 using System.Linq;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Dlna;
 
 
@@ -10,6 +12,7 @@ namespace Emby.Dlna.Profiles
     {
     {
         public DefaultProfile()
         public DefaultProfile()
         {
         {
+            Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
             Name = "Generic Device";
             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:*";
             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
             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",
                 Manufacturer = "Sony",
 
 
                 Headers = new[]
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     new HttpHeaderInfo
                     {
                     {
                         Name = "X-AV-Client-Info",
                         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
                         Match = HeaderMatchType.Regex
                     }
                     }
                 }
                 }

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

@@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles
 
 
             Identification = new DeviceIdentification
             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",
                 Manufacturer = "Sony",
 
 
                 Headers = new[]
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     new HttpHeaderInfo
                     {
                     {
                         Name = "X-AV-Client-Info",
                         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
                         Match = HeaderMatchType.Regex
                     }
                     }
                 }
                 }

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

@@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles
 
 
             Identification = new DeviceIdentification
             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",
                 Manufacturer = "Sony",
 
 
                 Headers = new[]
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     new HttpHeaderInfo
                     {
                     {
                         Name = "X-AV-Client-Info",
                         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
                         Match = HeaderMatchType.Regex
                     }
                     }
                 }
                 }

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

@@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles
 
 
             Identification = new DeviceIdentification
             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",
                 Manufacturer = "Sony",
 
 
                 Headers = new[]
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     new HttpHeaderInfo
                     {
                     {
                         Name = "X-AV-Client-Info",
                         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
                         Match = HeaderMatchType.Regex
                     }
                     }
                 }
                 }

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

@@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles
 
 
             Identification = new DeviceIdentification
             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",
                 Manufacturer = "Sony",
 
 
                 Headers = new[]
                 Headers = new[]
@@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles
                     new HttpHeaderInfo
                     new HttpHeaderInfo
                     {
                     {
                         Name = "X-AV-Client-Info",
                         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
                         Match = HeaderMatchType.Regex
                     }
                     }
                 }
                 }

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

@@ -3,10 +3,10 @@
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2010)</Name>
   <Name>Sony Bravia (2010)</Name>
   <Identification>
   <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>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
     <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>
     </Headers>
   </Identification>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>
   <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">
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2011)</Name>
   <Name>Sony Bravia (2011)</Name>
   <Identification>
   <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>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
     <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>
     </Headers>
   </Identification>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>
   <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">
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2012)</Name>
   <Name>Sony Bravia (2012)</Name>
   <Identification>
   <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>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
     <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>
     </Headers>
   </Identification>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>
   <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">
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2013)</Name>
   <Name>Sony Bravia (2013)</Name>
   <Identification>
   <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>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
     <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>
     </Headers>
   </Identification>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>
   <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">
   xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Name>Sony Bravia (2014)</Name>
   <Name>Sony Bravia (2014)</Name>
   <Identification>
   <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>
     <Manufacturer>Sony</Manufacturer>
     <Headers>
     <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>
     </Headers>
   </Identification>
   </Identification>
   <Manufacturer>Microsoft Corporation</Manufacturer>
   <Manufacturer>Microsoft Corporation</Manufacturer>

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

@@ -15,7 +15,6 @@ namespace Emby.Dlna.Server
     {
     {
         private readonly DeviceProfile _profile;
         private readonly DeviceProfile _profile;
 
 
-        private readonly CultureInfo _usCulture = new CultureInfo("en-US");
         private readonly string _serverUdn;
         private readonly string _serverUdn;
         private readonly string _serverAddress;
         private readonly string _serverAddress;
         private readonly string _serverName;
         private readonly string _serverName;
@@ -193,10 +192,10 @@ namespace Emby.Dlna.Server
                     .Append(SecurityElement.Escape(icon.MimeType ?? string.Empty))
                     .Append(SecurityElement.Escape(icon.MimeType ?? string.Empty))
                     .Append("</mimetype>");
                     .Append("</mimetype>");
                 builder.Append("<width>")
                 builder.Append("<width>")
-                    .Append(SecurityElement.Escape(icon.Width.ToString(_usCulture)))
+                    .Append(SecurityElement.Escape(icon.Width.ToString(CultureInfo.InvariantCulture)))
                     .Append("</width>");
                     .Append("</width>");
                 builder.Append("<height>")
                 builder.Append("<height>")
-                    .Append(SecurityElement.Escape(icon.Height.ToString(_usCulture)))
+                    .Append(SecurityElement.Escape(icon.Height.ToString(CultureInfo.InvariantCulture)))
                     .Append("</height>");
                     .Append("</height>");
                 builder.Append("<depth>")
                 builder.Append("<depth>")
                     .Append(SecurityElement.Escape(icon.Depth ?? string.Empty))
                     .Append(SecurityElement.Escape(icon.Depth ?? string.Empty))

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

@@ -6,9 +6,9 @@ using System.IO;
 using System.Text;
 using System.Text;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using System.Xml;
 using System.Xml;
+using Diacritics.Extensions;
 using Emby.Dlna.Didl;
 using Emby.Dlna.Didl;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Extensions;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
 namespace Emby.Dlna.Service
 namespace Emby.Dlna.Service
@@ -47,7 +47,7 @@ namespace Emby.Dlna.Service
 
 
         private async Task<ControlResponse> ProcessControlRequestInternalAsync(ControlRequest request)
         private async Task<ControlResponse> ProcessControlRequestInternalAsync(ControlRequest request)
         {
         {
-            ControlRequestInfo requestInfo = null;
+            ControlRequestInfo? requestInfo = null;
 
 
             using (var streamReader = new StreamReader(request.InputXml, Encoding.UTF8))
             using (var streamReader = new StreamReader(request.InputXml, Encoding.UTF8))
             {
             {
@@ -64,7 +64,7 @@ namespace Emby.Dlna.Service
                 requestInfo = await ParseRequestAsync(reader).ConfigureAwait(false);
                 requestInfo = await ParseRequestAsync(reader).ConfigureAwait(false);
             }
             }
 
 
-            Logger.LogDebug("Received control request {0}", requestInfo.LocalName);
+            Logger.LogDebug("Received control request {LocalName}, params: {@Headers}", requestInfo.LocalName, requestInfo.Headers);
 
 
             var settings = new XmlWriterSettings
             var settings = new XmlWriterSettings
             {
             {
@@ -95,11 +95,7 @@ namespace Emby.Dlna.Service
 
 
             var xml = builder.ToString().Replace("xmlns:m=", "xmlns:u=", StringComparison.Ordinal);
             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);
             controlResponse.Headers.Add("EXT", string.Empty);
 
 
@@ -151,7 +147,7 @@ namespace Emby.Dlna.Service
 
 
         private async Task<ControlRequestInfo> ParseBodyTagAsync(XmlReader reader)
         private async Task<ControlRequestInfo> ParseBodyTagAsync(XmlReader reader)
         {
         {
-            string namespaceURI = null, localName = null;
+            string? namespaceURI = null, localName = null;
 
 
             await reader.MoveToContentAsync().ConfigureAwait(false);
             await reader.MoveToContentAsync().ConfigureAwait(false);
             await reader.ReadAsync().ConfigureAwait(false);
             await reader.ReadAsync().ConfigureAwait(false);
@@ -210,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)
         private void LogRequest(ControlRequest request)
         {
         {

+ 4 - 4
Emby.Dlna/Service/BaseService.cs

@@ -23,14 +23,14 @@ namespace Emby.Dlna.Service
             return EventManager.CancelEventSubscription(subscriptionId);
             return EventManager.CancelEventSubscription(subscriptionId);
         }
         }
 
 
-        public EventSubscriptionResponse RenewEventSubscription(string subscriptionId, string notificationType, string timeoutString, string callbackUrl)
+        public EventSubscriptionResponse RenewEventSubscription(string subscriptionId, string notificationType, string requestedTimeoutString, string callbackUrl)
         {
         {
-            return EventManager.RenewEventSubscription(subscriptionId, notificationType, timeoutString, callbackUrl);
+            return EventManager.RenewEventSubscription(subscriptionId, notificationType, requestedTimeoutString, callbackUrl);
         }
         }
 
 
-        public EventSubscriptionResponse CreateEventSubscription(string notificationType, string timeoutString, string callbackUrl)
+        public EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl)
         {
         {
-            return EventManager.CreateEventSubscription(notificationType, timeoutString, callbackUrl);
+            return EventManager.CreateEventSubscription(notificationType, requestedTimeoutString, callbackUrl);
         }
         }
     }
     }
 }
 }

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

@@ -46,11 +46,7 @@ namespace Emby.Dlna.Service
                 writer.WriteEndDocument();
                 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
 #pragma warning disable CS1591
 
 
 using System;
 using System;
@@ -69,7 +71,7 @@ namespace Emby.Dlna.Ssdp
         {
         {
             lock (_syncLock)
             lock (_syncLock)
             {
             {
-                if (_listenerCount > 0 && _deviceLocator == null)
+                if (_listenerCount > 0 && _deviceLocator == null && _commsServer != null)
                 {
                 {
                     _deviceLocator = new SsdpDeviceLocator(_commsServer);
                     _deviceLocator = new SsdpDeviceLocator(_commsServer);
 
 
@@ -104,7 +106,7 @@ namespace Emby.Dlna.Ssdp
                 {
                 {
                     Location = e.DiscoveredDevice.DescriptionLocation,
                     Location = e.DiscoveredDevice.DescriptionLocation,
                     Headers = headers,
                     Headers = headers,
-                    LocalIpAddress = e.LocalIpAddress
+                    RemoteIpAddress = e.RemoteIpAddress
                 });
                 });
 
 
             DeviceDiscoveredInternal?.Invoke(this, args);
             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 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);
             var node = container.Element(name);
 
 
             return node?.Value;
             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);
             var node = container.Attribute(name);
 
 
             return node?.Value;
             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;
             => container.Descendants(name).FirstOrDefault()?.Value;
     }
     }
 }
 }

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

@@ -6,11 +6,9 @@
   </PropertyGroup>
   </PropertyGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
-    <Nullable>enable</Nullable>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -25,14 +23,9 @@
 
 
   <!-- Code analysers-->
   <!-- Code analysers-->
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
   <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="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
     <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
     <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
   </ItemGroup>
   </ItemGroup>
 
 
-  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
-  </PropertyGroup>
-
 </Project>
 </Project>

+ 104 - 25
Emby.Drawing/ImageProcessor.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
+using System.Text;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
@@ -25,7 +26,7 @@ namespace Emby.Drawing
     public sealed class ImageProcessor : IImageProcessor, IDisposable
     public sealed class ImageProcessor : IImageProcessor, IDisposable
     {
     {
         // Increment this when there's a change requiring caches to be invalidated
         // Increment this when there's a change requiring caches to be invalidated
-        private const string Version = "3";
+        private const char Version = '3';
 
 
         private static readonly HashSet<string> _transparentImageTypes
         private static readonly HashSet<string> _transparentImageTypes
             = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
             = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
@@ -101,7 +102,7 @@ namespace Emby.Drawing
         {
         {
             var file = await ProcessImage(options).ConfigureAwait(false);
             var file = await ProcessImage(options).ConfigureAwait(false);
 
 
-            using (var fileStream = new FileStream(file.Item1, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, true))
+            using (var fileStream = AsyncFile.OpenRead(file.Item1))
             {
             {
                 await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
                 await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
             }
             }
@@ -171,21 +172,31 @@ namespace Emby.Drawing
                 return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
                 return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
             }
             }
 
 
-            ImageDimensions newSize = ImageHelper.GetNewImageSize(options, null);
             int quality = options.Quality;
             int quality = options.Quality;
 
 
             ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
             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
             try
             {
             {
                 if (!File.Exists(cacheFilePath))
                 if (!File.Exists(cacheFilePath))
                 {
                 {
-                    if (options.CropWhiteSpace && !SupportsTransparency(originalImagePath))
-                    {
-                        options.CropWhiteSpace = false;
-                    }
-
                     string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
                     string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
 
 
                     if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
                     if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
@@ -246,48 +257,111 @@ namespace Emby.Drawing
         /// <summary>
         /// <summary>
         /// Gets the cache file path based on a set of parameters.
         /// Gets the cache file path based on a set of parameters.
         /// </summary>
         /// </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)
             if (addPlayedIndicator)
             {
             {
-                filename += "pl=true";
+                filename.Append(",pl=true");
             }
             }
 
 
             if (percentPlayed > 0)
             if (percentPlayed > 0)
             {
             {
-                filename += "p=" + percentPlayed;
+                filename.Append(",p=");
+                filename.Append(percentPlayed);
             }
             }
 
 
             if (unwatchedCount.HasValue)
             if (unwatchedCount.HasValue)
             {
             {
-                filename += "p=" + unwatchedCount.Value;
+                filename.Append(",p=");
+                filename.Append(unwatchedCount.Value);
             }
             }
 
 
             if (blur.HasValue)
             if (blur.HasValue)
             {
             {
-                filename += "blur=" + blur.Value;
+                filename.Append(",blur=");
+                filename.Append(blur.Value);
             }
             }
 
 
             if (!string.IsNullOrEmpty(backgroundColor))
             if (!string.IsNullOrEmpty(backgroundColor))
             {
             {
-                filename += "b=" + backgroundColor;
+                filename.Append(",b=");
+                filename.Append(backgroundColor);
             }
             }
 
 
             if (!string.IsNullOrEmpty(foregroundLayer))
             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 />
         /// <inheritdoc />
@@ -352,8 +426,13 @@ namespace Emby.Drawing
         }
         }
 
 
         /// <inheritdoc />
         /// <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()
             return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
                 .ToString("N", CultureInfo.InvariantCulture);
                 .ToString("N", CultureInfo.InvariantCulture);
         }
         }

+ 1 - 1
Emby.Drawing/NullImageEncoder.cs

@@ -32,7 +32,7 @@ namespace Emby.Drawing
             => throw new NotImplementedException();
             => throw new NotImplementedException();
 
 
         /// <inheritdoc />
         /// <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();
             throw new NotImplementedException();
         }
         }

+ 3 - 3
Emby.Naming/Audio/AudioFileParser.cs

@@ -1,7 +1,7 @@
 using System;
 using System;
 using System.IO;
 using System.IO;
-using System.Linq;
 using Emby.Naming.Common;
 using Emby.Naming.Common;
+using Jellyfin.Extensions;
 
 
 namespace Emby.Naming.Audio
 namespace Emby.Naming.Audio
 {
 {
@@ -18,8 +18,8 @@ namespace Emby.Naming.Audio
         /// <returns>True if file at path is audio file.</returns>
         /// <returns>True if file at path is audio file.</returns>
         public static bool IsAudioFile(string path, NamingOptions options)
         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);
         }
         }
     }
     }
 }
 }

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

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

+ 7 - 7
Emby.Naming/AudioBook/AudioBookListResolver.cs

@@ -14,6 +14,7 @@ namespace Emby.Naming.AudioBook
     public class AudioBookListResolver
     public class AudioBookListResolver
     {
     {
         private readonly NamingOptions _options;
         private readonly NamingOptions _options;
+        private readonly AudioBookResolver _audioBookResolver;
 
 
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="AudioBookListResolver"/> class.
         /// Initializes a new instance of the <see cref="AudioBookListResolver"/> class.
@@ -22,6 +23,7 @@ namespace Emby.Naming.AudioBook
         public AudioBookListResolver(NamingOptions options)
         public AudioBookListResolver(NamingOptions options)
         {
         {
             _options = options;
             _options = options;
+            _audioBookResolver = new AudioBookResolver(_options);
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -31,21 +33,19 @@ namespace Emby.Naming.AudioBook
         /// <returns>Returns IEnumerable of <see cref="AudioBookInfo"/>.</returns>
         /// <returns>Returns IEnumerable of <see cref="AudioBookInfo"/>.</returns>
         public IEnumerable<AudioBookInfo> Resolve(IEnumerable<FileSystemMetadata> files)
         public IEnumerable<AudioBookInfo> Resolve(IEnumerable<FileSystemMetadata> files)
         {
         {
-            var audioBookResolver = new AudioBookResolver(_options);
 
 
             // File with empty fullname will be sorted out here.
             // File with empty fullname will be sorted out here.
             var audiobookFileInfos = files
             var audiobookFileInfos = files
-                .Select(i => audioBookResolver.Resolve(i.FullName))
+                .Select(i => _audioBookResolver.Resolve(i.FullName))
                 .OfType<AudioBookFileInfo>()
                 .OfType<AudioBookFileInfo>()
                 .ToList();
                 .ToList();
 
 
-            var stackResult = new StackResolver(_options)
-                .ResolveAudioBooks(audiobookFileInfos);
+            var stackResult = StackResolver.ResolveAudioBooks(audiobookFileInfos);
 
 
             foreach (var stack in stackResult)
             foreach (var stack in stackResult)
             {
             {
                 var stackFiles = stack.Files
                 var stackFiles = stack.Files
-                    .Select(i => audioBookResolver.Resolve(i))
+                    .Select(i => _audioBookResolver.Resolve(i))
                     .OfType<AudioBookFileInfo>()
                     .OfType<AudioBookFileInfo>()
                     .ToList();
                     .ToList();
 
 
@@ -73,7 +73,7 @@ namespace Emby.Naming.AudioBook
 
 
             var haveChaptersOrPages = stackFiles.Any(x => x.ChapterNumber != null || x.PartNumber != null);
             var haveChaptersOrPages = stackFiles.Any(x => x.ChapterNumber != null || x.PartNumber != null);
             var groupedBy = stackFiles.GroupBy(file => new { file.ChapterNumber, file.PartNumber });
             var groupedBy = stackFiles.GroupBy(file => new { file.ChapterNumber, file.PartNumber });
-            var nameWithReplacedDots = nameParserResult.Name.Replace(" ", ".");
+            var nameWithReplacedDots = nameParserResult.Name.Replace(' ', '.');
 
 
             foreach (var group in groupedBy)
             foreach (var group in groupedBy)
             {
             {
@@ -87,7 +87,7 @@ namespace Emby.Naming.AudioBook
                         foreach (var audioFile in group)
                         foreach (var audioFile in group)
                         {
                         {
                             var name = Path.GetFileNameWithoutExtension(audioFile.Path);
                             var name = Path.GetFileNameWithoutExtension(audioFile.Path);
-                            if (name.Equals("audiobook") ||
+                            if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) ||
                                 name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) ||
                                 name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) ||
                                 name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase))
                                 name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase))
                             {
                             {

+ 2 - 2
Emby.Naming/AudioBook/AudioBookResolver.cs

@@ -1,7 +1,7 @@
 using System;
 using System;
 using System.IO;
 using System.IO;
-using System.Linq;
 using Emby.Naming.Common;
 using Emby.Naming.Common;
+using Jellyfin.Extensions;
 
 
 namespace Emby.Naming.AudioBook
 namespace Emby.Naming.AudioBook
 {
 {
@@ -37,7 +37,7 @@ namespace Emby.Naming.AudioBook
             var extension = Path.GetExtension(path);
             var extension = Path.GetExtension(path);
 
 
             // Check supported extensions
             // Check supported extensions
-            if (!_options.AudioFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
+            if (!_options.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
             {
             {
                 return null;
                 return null;
             }
             }

+ 79 - 24
Emby.Naming/Common/NamingOptions.cs

@@ -1,4 +1,7 @@
+#pragma warning disable CA1819
+
 using System;
 using System;
+using System.Collections.Generic;
 using System.Linq;
 using System.Linq;
 using System.Text.RegularExpressions;
 using System.Text.RegularExpressions;
 using Emby.Naming.Video;
 using Emby.Naming.Video;
@@ -122,11 +125,11 @@ namespace Emby.Naming.Common
                     token: "DSR")
                     token: "DSR")
             };
             };
 
 
-            VideoFileStackingExpressions = new[]
+            VideoFileStackingRules = new[]
             {
             {
-                "(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(?<ignore>.*?)(?<extension>\\.[^.]+)$",
-                "(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$",
-                "(?<title>.*?)(?<volume>[ ._-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$"
+                new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[0-9]+)[\)\]]?(?:\.[^.]+)?$", true),
+                new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[a-d])[\)\]]?(?:\.[^.]+)?$", false),
+                new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]?)(?<number>[a-d])(?:\.[^.]+)?$", false)
             };
             };
 
 
             CleanDateTimes = new[]
             CleanDateTimes = new[]
@@ -137,8 +140,11 @@ namespace Emby.Naming.Common
 
 
             CleanStrings = new[]
             CleanStrings = new[]
             {
             {
-                @"[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
-                @"(\[.*\])"
+                @"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
+                @"^(?<cleaned>.+?)(\[.*\])",
+                @"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
+                @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
+                @"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$"
             };
             };
 
 
             SubtitleFileExtensions = new[]
             SubtitleFileExtensions = new[]
@@ -250,6 +256,8 @@ namespace Emby.Naming.Common
                 },
                 },
                 // <!-- foo.ep01, foo.EP_01 -->
                 // <!-- foo.ep01, foo.EP_01 -->
                 new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"),
                 new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"),
+                // <!-- foo.E01., foo.e01. -->
+                new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"),
                 new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true)
                 new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true)
                 {
                 {
                     DateTimeFormats = new[]
                     DateTimeFormats = new[]
@@ -277,12 +285,18 @@ namespace Emby.Naming.Common
                     IsNamed = true
                     IsNamed = true
                 },
                 },
 
 
-                new EpisodeExpression("[\\\\/\\._ \\[\\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\\.[1-9])(?![0-9]))?)([^\\\\/]*)$")
+                new EpisodeExpression(@"[\\\/\._ \[\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\.[1-9])(?![0-9]))?)([^\\\/]*)$")
                 {
                 {
                     SupportsAbsoluteEpisodeNumbers = true
                     SupportsAbsoluteEpisodeNumbers = true
                 },
                 },
 
 
-                // Case Closed (1996-2007)/Case Closed - 317.mkv
+                // Not a Kodi rule as well, but below rule also causes false positives for triple-digit episode names
+                // [bar] Foo - 1 [baz] special case of below expression to prevent false positives with digits in the series name
+                new EpisodeExpression(@".*[\\\/]?.*?(\[.*?\])+.*?(?<seriesname>[-\w\s]+?)[\s_]*-[\s_]*(?<epnumber>[0-9]+).*$")
+                {
+                    IsNamed = true
+                },
+
                 // /server/anything_102.mp4
                 // /server/anything_102.mp4
                 // /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv
                 // /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv
                 // /server/anything_1996.11.14.mp4
                 // /server/anything_1996.11.14.mp4
@@ -299,11 +313,12 @@ namespace Emby.Naming.Common
 
 
                 // *** End Kodi Standard Naming
                 // *** End Kodi Standard Naming
 
 
-                // [bar] Foo - 1 [baz]
-                new EpisodeExpression(@".*?(\[.*?\])+.*?(?<seriesname>[\w\s]+?)[-\s_]+(?<epnumber>[0-9]+).*$")
+                // "Episode 16", "Episode 16 - Title"
+                new EpisodeExpression(@"[Ee]pisode (?<epnumber>[0-9]+)(-(?<endingepnumber>[0-9]+))?[^\\\/]*$")
                 {
                 {
                     IsNamed = true
                     IsNamed = true
                 },
                 },
+
                 new EpisodeExpression(@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]+)[xX](?<epnumber>[0-9]+)[^\\\/]*$")
                 new EpisodeExpression(@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]+)[xX](?<epnumber>[0-9]+)[^\\\/]*$")
                 {
                 {
                     IsNamed = true
                     IsNamed = true
@@ -361,12 +376,20 @@ namespace Emby.Naming.Common
                     IsOptimistic = true,
                     IsOptimistic = true,
                     IsNamed = true
                     IsNamed = true
                 },
                 },
-                // "Episode 16", "Episode 16 - Title"
-                new EpisodeExpression(@".*[\\\/][^\\\/]* (?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*[^\\\/]*$")
+
+                // Series and season only expression
+                // "the show/season 1", "the show/s01"
+                new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)\/[Ss](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)")
                 {
                 {
-                    IsOptimistic = true,
                     IsNamed = true
                     IsNamed = true
-                }
+                },
+
+                // Series and season only expression
+                // "the show S01", "the show season 1"
+                new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)[\. _\-]+[sS](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)")
+                {
+                    IsNamed = true
+                },
             };
             };
 
 
             EpisodeWithoutSeasonExpressions = new[]
             EpisodeWithoutSeasonExpressions = new[]
@@ -381,6 +404,12 @@ namespace Emby.Naming.Common
 
 
             VideoExtraRules = new[]
             VideoExtraRules = new[]
             {
             {
+                new ExtraRule(
+                    ExtraType.Trailer,
+                    ExtraRuleType.DirectoryName,
+                    "trailers",
+                    MediaType.Video),
+
                 new ExtraRule(
                 new ExtraRule(
                     ExtraType.Trailer,
                     ExtraType.Trailer,
                     ExtraRuleType.Filename,
                     ExtraRuleType.Filename,
@@ -447,6 +476,12 @@ namespace Emby.Naming.Common
                     "theme",
                     "theme",
                     MediaType.Audio),
                     MediaType.Audio),
 
 
+                new ExtraRule(
+                    ExtraType.ThemeSong,
+                    ExtraRuleType.DirectoryName,
+                    "theme-music",
+                    MediaType.Audio),
+
                 new ExtraRule(
                 new ExtraRule(
                     ExtraType.Scene,
                     ExtraType.Scene,
                     ExtraRuleType.Suffix,
                     ExtraRuleType.Suffix,
@@ -477,6 +512,12 @@ namespace Emby.Naming.Common
                     "-deleted",
                     "-deleted",
                     MediaType.Video),
                     MediaType.Video),
 
 
+                new ExtraRule(
+                    ExtraType.DeletedScene,
+                    ExtraRuleType.Suffix,
+                    "-deletedscene",
+                    MediaType.Video),
+
                 new ExtraRule(
                 new ExtraRule(
                     ExtraType.Clip,
                     ExtraType.Clip,
                     ExtraRuleType.Suffix,
                     ExtraRuleType.Suffix,
@@ -535,7 +576,7 @@ namespace Emby.Naming.Common
                     ExtraType.Unknown,
                     ExtraType.Unknown,
                     ExtraRuleType.DirectoryName,
                     ExtraRuleType.DirectoryName,
                     "extras",
                     "extras",
-                    MediaType.Video),
+                    MediaType.Video)
             };
             };
 
 
             Format3DRules = new[]
             Format3DRules = new[]
@@ -587,7 +628,7 @@ namespace Emby.Naming.Common
             AudioBookNamesExpressions = new[]
             AudioBookNamesExpressions = new[]
             {
             {
                 // Detect year usually in brackets after name Batman (2020)
                 // Detect year usually in brackets after name Batman (2020)
-                @"^(?<name>.+?)\s*\(\s*(?<year>\d{4})\s*\)\s*$",
+                @"^(?<name>.+?)\s*\(\s*(?<year>[0-9]{4})\s*\)\s*$",
                 @"^\s*(?<name>[^ ].*?)\s*$"
                 @"^\s*(?<name>[^ ].*?)\s*$"
             };
             };
 
 
@@ -647,9 +688,29 @@ namespace Emby.Naming.Common
                 .Distinct(StringComparer.OrdinalIgnoreCase)
                 .Distinct(StringComparer.OrdinalIgnoreCase)
                 .ToArray();
                 .ToArray();
 
 
+            AllExtrasTypesFolderNames = new Dictionary<string, ExtraType>(StringComparer.OrdinalIgnoreCase)
+            {
+                ["trailers"] = ExtraType.Trailer,
+                ["theme-music"] = ExtraType.ThemeSong,
+                ["backdrops"] = ExtraType.ThemeVideo,
+                ["extras"] = ExtraType.Unknown,
+                ["behind the scenes"] = ExtraType.BehindTheScenes,
+                ["deleted scenes"] = ExtraType.DeletedScene,
+                ["interviews"] = ExtraType.Interview,
+                ["scenes"] = ExtraType.Scene,
+                ["samples"] = ExtraType.Sample,
+                ["shorts"] = ExtraType.Clip,
+                ["featurettes"] = ExtraType.Clip
+            };
+
             Compile();
             Compile();
         }
         }
 
 
+        /// <summary>
+        /// Gets or sets the folder name to extra types mapping.
+        /// </summary>
+        public Dictionary<string, ExtraType> AllExtrasTypesFolderNames { get; set; }
+
         /// <summary>
         /// <summary>
         /// Gets or sets list of audio file extensions.
         /// Gets or sets list of audio file extensions.
         /// </summary>
         /// </summary>
@@ -731,9 +792,9 @@ namespace Emby.Naming.Common
         public Format3DRule[] Format3DRules { get; set; }
         public Format3DRule[] Format3DRules { get; set; }
 
 
         /// <summary>
         /// <summary>
-        /// Gets or sets list of raw video file-stacking expressions strings.
+        /// Gets the file stacking rules.
         /// </summary>
         /// </summary>
-        public string[] VideoFileStackingExpressions { get; set; }
+        public FileStackRule[] VideoFileStackingRules { get; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets list of raw clean DateTimes regular expressions strings.
         /// Gets or sets list of raw clean DateTimes regular expressions strings.
@@ -755,11 +816,6 @@ namespace Emby.Naming.Common
         /// </summary>
         /// </summary>
         public ExtraRule[] VideoExtraRules { get; set; }
         public ExtraRule[] VideoExtraRules { get; set; }
 
 
-        /// <summary>
-        /// Gets list of video file-stack regular expressions.
-        /// </summary>
-        public Regex[] VideoFileStackingRegexes { get; private set; } = Array.Empty<Regex>();
-
         /// <summary>
         /// <summary>
         /// Gets list of clean datetime regular expressions.
         /// Gets list of clean datetime regular expressions.
         /// </summary>
         /// </summary>
@@ -785,7 +841,6 @@ namespace Emby.Naming.Common
         /// </summary>
         /// </summary>
         public void Compile()
         public void Compile()
         {
         {
-            VideoFileStackingRegexes = VideoFileStackingExpressions.Select(Compile).ToArray();
             CleanDateTimeRegexes = CleanDateTimes.Select(Compile).ToArray();
             CleanDateTimeRegexes = CleanDateTimes.Select(Compile).ToArray();
             CleanStringRegexes = CleanStrings.Select(Compile).ToArray();
             CleanStringRegexes = CleanStrings.Select(Compile).ToArray();
             EpisodeWithoutSeasonRegexes = EpisodeWithoutSeasonExpressions.Select(Compile).ToArray();
             EpisodeWithoutSeasonRegexes = EpisodeWithoutSeasonExpressions.Select(Compile).ToArray();

+ 7 - 13
Emby.Naming/Emby.Naming.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
 
   <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
   <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
   <PropertyGroup>
   <PropertyGroup>
@@ -6,15 +6,13 @@
   </PropertyGroup>
   </PropertyGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
     <PublishRepositoryUrl>true</PublishRepositoryUrl>
     <PublishRepositoryUrl>true</PublishRepositoryUrl>
     <EmbedUntrackedSources>true</EmbedUntrackedSources>
     <EmbedUntrackedSources>true</EmbedUntrackedSources>
     <IncludeSymbols>true</IncludeSymbols>
     <IncludeSymbols>true</IncludeSymbols>
     <SymbolPackageFormat>snupkg</SymbolPackageFormat>
     <SymbolPackageFormat>snupkg</SymbolPackageFormat>
-    <Nullable>enable</Nullable>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <PropertyGroup Condition=" '$(Stability)'=='Unstable'">
   <PropertyGroup Condition=" '$(Stability)'=='Unstable'">
@@ -23,35 +21,31 @@
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
-    <Compile Include="..\SharedVersion.cs" />
+    <Compile Include="../SharedVersion.cs" />
   </ItemGroup>
   </ItemGroup>
 
 
   <ItemGroup>
   <ItemGroup>
-    <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
+    <ProjectReference Include="../MediaBrowser.Common/MediaBrowser.Common.csproj" />
+    <ProjectReference Include="../MediaBrowser.Model/MediaBrowser.Model.csproj" />
   </ItemGroup>
   </ItemGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
     <Authors>Jellyfin Contributors</Authors>
     <Authors>Jellyfin Contributors</Authors>
     <PackageId>Jellyfin.Naming</PackageId>
     <PackageId>Jellyfin.Naming</PackageId>
-    <VersionPrefix>10.7.0</VersionPrefix>
+    <VersionPrefix>10.8.0</VersionPrefix>
     <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
     <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
     <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
     <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
-    <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
+    <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
   </ItemGroup>
   </ItemGroup>
 
 
   <!-- Code Analyzers-->
   <!-- Code Analyzers-->
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <!-- TODO: <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> -->
     <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
     <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
     <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
     <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
   </ItemGroup>
   </ItemGroup>
 
 
-  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
-  </PropertyGroup>
-
 </Project>
 </Project>

+ 6 - 5
Emby.Naming/Subtitles/SubtitleParser.cs

@@ -2,6 +2,7 @@ using System;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 using Emby.Naming.Common;
 using Emby.Naming.Common;
+using Jellyfin.Extensions;
 
 
 namespace Emby.Naming.Subtitles
 namespace Emby.Naming.Subtitles
 {
 {
@@ -34,7 +35,7 @@ namespace Emby.Naming.Subtitles
             }
             }
 
 
             var extension = Path.GetExtension(path);
             var extension = Path.GetExtension(path);
-            if (!_options.SubtitleFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
+            if (!_options.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
             {
             {
                 return null;
                 return null;
             }
             }
@@ -42,11 +43,11 @@ namespace Emby.Naming.Subtitles
             var flags = GetFlags(path);
             var flags = GetFlags(path);
             var info = new SubtitleInfo(
             var info = new SubtitleInfo(
                 path,
                 path,
-                _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)),
-                _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)));
+                _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)),
+                _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)));
 
 
-            var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparer.OrdinalIgnoreCase)
-                && !_options.SubtitleForcedFlags.Contains(i, StringComparer.OrdinalIgnoreCase))
+            var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparison.OrdinalIgnoreCase)
+                && !_options.SubtitleForcedFlags.Contains(i, StringComparison.OrdinalIgnoreCase))
                 .ToList();
                 .ToList();
 
 
             // Should have a name, language and file extension
             // Should have a name, language and file extension

+ 9 - 5
Emby.Naming/TV/EpisodeResolver.cs

@@ -1,8 +1,8 @@
 using System;
 using System;
 using System.IO;
 using System.IO;
-using System.Linq;
 using Emby.Naming.Common;
 using Emby.Naming.Common;
 using Emby.Naming.Video;
 using Emby.Naming.Video;
+using Jellyfin.Extensions;
 
 
 namespace Emby.Naming.TV
 namespace Emby.Naming.TV
 {
 {
@@ -16,7 +16,7 @@ namespace Emby.Naming.TV
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="EpisodeResolver"/> class.
         /// Initializes a new instance of the <see cref="EpisodeResolver"/> class.
         /// </summary>
         /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions and passed to <see cref="StubResolver"/>, <see cref="FlagParser"/>, <see cref="Format3DParser"/> and <see cref="EpisodePathParser"/>.</param>
+        /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions and passed to <see cref="StubResolver"/>, <see cref="Format3DParser"/> and <see cref="EpisodePathParser"/>.</param>
         public EpisodeResolver(NamingOptions options)
         public EpisodeResolver(NamingOptions options)
         {
         {
             _options = options;
             _options = options;
@@ -48,7 +48,7 @@ namespace Emby.Naming.TV
             {
             {
                 var extension = Path.GetExtension(path);
                 var extension = Path.GetExtension(path);
                 // Check supported extensions
                 // Check supported extensions
-                if (!_options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
+                if (!_options.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
                 {
                 {
                     // It's not supported. Check stub extensions
                     // It's not supported. Check stub extensions
                     if (!StubResolver.TryResolveFile(path, _options, out stubType))
                     if (!StubResolver.TryResolveFile(path, _options, out stubType))
@@ -62,12 +62,16 @@ namespace Emby.Naming.TV
                 container = extension.TrimStart('.');
                 container = extension.TrimStart('.');
             }
             }
 
 
-            var flags = new FlagParser(_options).GetFlags(path);
-            var format3DResult = new Format3DParser(_options).Parse(flags);
+            var format3DResult = Format3DParser.Parse(path, _options);
 
 
             var parsingResult = new EpisodePathParser(_options)
             var parsingResult = new EpisodePathParser(_options)
                 .Parse(path, isDirectory, isNamed, isOptimistic, supportsAbsoluteNumbers, fillExtendedInfo);
                 .Parse(path, isDirectory, isNamed, isOptimistic, supportsAbsoluteNumbers, fillExtendedInfo);
 
 
+            if (!parsingResult.Success && !isStub)
+            {
+                return null;
+            }
+
             return new EpisodeInfo(path)
             return new EpisodeInfo(path)
             {
             {
                 Container = container,
                 Container = container,

+ 1 - 1
Emby.Naming/TV/SeasonPathParser.cs

@@ -60,7 +60,7 @@ namespace Emby.Naming.TV
             bool supportSpecialAliases,
             bool supportSpecialAliases,
             bool supportNumericSeasonFolders)
             bool supportNumericSeasonFolders)
         {
         {
-            var filename = Path.GetFileName(path) ?? string.Empty;
+            string filename = Path.GetFileName(path);
 
 
             if (supportSpecialAliases)
             if (supportSpecialAliases)
             {
             {

+ 29 - 0
Emby.Naming/TV/SeriesInfo.cs

@@ -0,0 +1,29 @@
+namespace Emby.Naming.TV
+{
+    /// <summary>
+    /// Holder object for Series information.
+    /// </summary>
+    public class SeriesInfo
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SeriesInfo"/> class.
+        /// </summary>
+        /// <param name="path">Path to the file.</param>
+        public SeriesInfo(string path)
+        {
+            Path = path;
+        }
+
+        /// <summary>
+        /// Gets or sets the path.
+        /// </summary>
+        /// <value>The path.</value>
+        public string Path { get; set; }
+
+        /// <summary>
+        /// Gets or sets the name of the series.
+        /// </summary>
+        /// <value>The name of the series.</value>
+        public string? Name { get; set; }
+    }
+}

+ 60 - 0
Emby.Naming/TV/SeriesPathParser.cs

@@ -0,0 +1,60 @@
+using Emby.Naming.Common;
+
+namespace Emby.Naming.TV
+{
+    /// <summary>
+    /// Used to parse information about series from paths containing more information that only the series name.
+    /// Uses the same regular expressions as the EpisodePathParser but have different success criteria.
+    /// </summary>
+    public static class SeriesPathParser
+    {
+        /// <summary>
+        /// Parses information about series from path.
+        /// </summary>
+        /// <param name="options"><see cref="NamingOptions"/> object containing EpisodeExpressions and MultipleEpisodeExpressions.</param>
+        /// <param name="path">Path.</param>
+        /// <returns>Returns <see cref="SeriesPathParserResult"/> object.</returns>
+        public static SeriesPathParserResult Parse(NamingOptions options, string path)
+        {
+            SeriesPathParserResult? result = null;
+
+            foreach (var expression in options.EpisodeExpressions)
+            {
+                var currentResult = Parse(path, expression);
+                if (currentResult.Success)
+                {
+                    result = currentResult;
+                    break;
+                }
+            }
+
+            if (result != null)
+            {
+                if (!string.IsNullOrEmpty(result.SeriesName))
+                {
+                    result.SeriesName = result.SeriesName.Trim(' ', '_', '.', '-');
+                }
+            }
+
+            return result ?? new SeriesPathParserResult();
+        }
+
+        private static SeriesPathParserResult Parse(string name, EpisodeExpression expression)
+        {
+            var result = new SeriesPathParserResult();
+
+            var match = expression.Regex.Match(name);
+
+            if (match.Success && match.Groups.Count >= 3)
+            {
+                if (expression.IsNamed)
+                {
+                    result.SeriesName = match.Groups["seriesname"].Value;
+                    result.Success = !string.IsNullOrEmpty(result.SeriesName) && !string.IsNullOrEmpty(match.Groups["seasonnumber"]?.Value);
+                }
+            }
+
+            return result;
+        }
+    }
+}

+ 19 - 0
Emby.Naming/TV/SeriesPathParserResult.cs

@@ -0,0 +1,19 @@
+namespace Emby.Naming.TV
+{
+    /// <summary>
+    /// Holder object for <see cref="SeriesPathParser"/> result.
+    /// </summary>
+    public class SeriesPathParserResult
+    {
+        /// <summary>
+        /// Gets or sets the name of the series.
+        /// </summary>
+        /// <value>The name of the series.</value>
+        public string? SeriesName { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether parsing was successful.
+        /// </summary>
+        public bool Success { get; set; }
+    }
+}

+ 49 - 0
Emby.Naming/TV/SeriesResolver.cs

@@ -0,0 +1,49 @@
+using System.IO;
+using System.Text.RegularExpressions;
+using Emby.Naming.Common;
+
+namespace Emby.Naming.TV
+{
+    /// <summary>
+    /// Used to resolve information about series from path.
+    /// </summary>
+    public static class SeriesResolver
+    {
+        /// <summary>
+        /// Regex that matches strings of at least 2 characters separated by a dot or underscore.
+        /// Used for removing separators between words, i.e turns "The_show" into "The show" while
+        /// preserving namings like "S.H.O.W".
+        /// </summary>
+        private static readonly Regex _seriesNameRegex = new Regex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))");
+
+        /// <summary>
+        /// Resolve information about series from path.
+        /// </summary>
+        /// <param name="options"><see cref="NamingOptions"/> object passed to <see cref="SeriesPathParser"/>.</param>
+        /// <param name="path">Path to series.</param>
+        /// <returns>SeriesInfo.</returns>
+        public static SeriesInfo Resolve(NamingOptions options, string path)
+        {
+            string seriesName = Path.GetFileName(path);
+
+            SeriesPathParserResult result = SeriesPathParser.Parse(options, path);
+            if (result.Success)
+            {
+                if (!string.IsNullOrEmpty(result.SeriesName))
+                {
+                    seriesName = result.SeriesName;
+                }
+            }
+
+            if (!string.IsNullOrEmpty(seriesName))
+            {
+                seriesName = _seriesNameRegex.Replace(seriesName, "${a} ${b}").Trim();
+            }
+
+            return new SeriesInfo(path)
+            {
+                Name = seriesName
+            };
+        }
+    }
+}

+ 18 - 11
Emby.Naming/Video/CleanStringParser.cs

@@ -1,5 +1,5 @@
-using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
 using System.Text.RegularExpressions;
 using System.Text.RegularExpressions;
 
 
 namespace Emby.Naming.Video
 namespace Emby.Naming.Video
@@ -16,28 +16,35 @@ namespace Emby.Naming.Video
         /// <param name="expressions">List of regex to parse name and year from.</param>
         /// <param name="expressions">List of regex to parse name and year from.</param>
         /// <param name="newName">Parsing result string.</param>
         /// <param name="newName">Parsing result string.</param>
         /// <returns>True if parsing was successful.</returns>
         /// <returns>True if parsing was successful.</returns>
-        public static bool TryClean(string name, IReadOnlyList<Regex> expressions, out ReadOnlySpan<char> newName)
+        public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList<Regex> expressions, out string newName)
         {
         {
-            var len = expressions.Count;
-            for (int i = 0; i < len; i++)
+            if (string.IsNullOrEmpty(name))
+            {
+                newName = string.Empty;
+                return false;
+            }
+
+            // Iteratively apply the regexps to clean the string.
+            bool cleaned = false;
+            for (int i = 0; i < expressions.Count; i++)
             {
             {
                 if (TryClean(name, expressions[i], out newName))
                 if (TryClean(name, expressions[i], out newName))
                 {
                 {
-                    return true;
+                    cleaned = true;
+                    name = newName;
                 }
                 }
             }
             }
 
 
-            newName = ReadOnlySpan<char>.Empty;
-            return false;
+            newName = cleaned ? name : string.Empty;
+            return cleaned;
         }
         }
 
 
-        private static bool TryClean(string name, Regex expression, out ReadOnlySpan<char> newName)
+        private static bool TryClean(string name, Regex expression, out string newName)
         {
         {
             var match = expression.Match(name);
             var match = expression.Match(name);
-            int index = match.Index;
-            if (match.Success && index != 0)
+            if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned))
             {
             {
-                newName = name.AsSpan().Slice(0, match.Index);
+                newName = cleaned.Value;
                 return true;
                 return true;
             }
             }
 
 

+ 104 - 55
Emby.Naming/Video/ExtraResolver.cs

@@ -1,4 +1,5 @@
 using System;
 using System;
+using System.Collections.Generic;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 using System.Text.RegularExpressions;
 using System.Text.RegularExpressions;
@@ -10,93 +11,141 @@ namespace Emby.Naming.Video
     /// <summary>
     /// <summary>
     /// Resolve if file is extra for video.
     /// Resolve if file is extra for video.
     /// </summary>
     /// </summary>
-    public class ExtraResolver
+    public static class ExtraResolver
     {
     {
-        private readonly NamingOptions _options;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ExtraResolver"/> class.
-        /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing VideoExtraRules and passed to <see cref="AudioFileParser"/> and <see cref="VideoResolver"/>.</param>
-        public ExtraResolver(NamingOptions options)
-        {
-            _options = options;
-        }
+        private static readonly char[] _digits = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
 
 
         /// <summary>
         /// <summary>
         /// Attempts to resolve if file is extra.
         /// Attempts to resolve if file is extra.
         /// </summary>
         /// </summary>
         /// <param name="path">Path to file.</param>
         /// <param name="path">Path to file.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Returns <see cref="ExtraResult"/> object.</returns>
         /// <returns>Returns <see cref="ExtraResult"/> object.</returns>
-        public ExtraResult GetExtraInfo(string path)
-        {
-            return _options.VideoExtraRules
-                .Select(i => GetExtraInfo(path, i))
-                .FirstOrDefault(i => i.ExtraType != null) ?? new ExtraResult();
-        }
-
-        private ExtraResult GetExtraInfo(string path, ExtraRule rule)
+        public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions)
         {
         {
             var result = new ExtraResult();
             var result = new ExtraResult();
 
 
-            if (rule.MediaType == MediaType.Audio)
+            for (var i = 0; i < namingOptions.VideoExtraRules.Length; i++)
             {
             {
-                if (!AudioFileParser.IsAudioFile(path, _options))
+                var rule = namingOptions.VideoExtraRules[i];
+                if ((rule.MediaType == MediaType.Audio && !AudioFileParser.IsAudioFile(path, namingOptions))
+                    || (rule.MediaType == MediaType.Video && !VideoResolver.IsVideoFile(path, namingOptions)))
                 {
                 {
-                    return result;
+                    continue;
                 }
                 }
-            }
-            else if (rule.MediaType == MediaType.Video)
-            {
-                if (!new VideoResolver(_options).IsVideoFile(path))
+
+                var pathSpan = path.AsSpan();
+                if (rule.RuleType == ExtraRuleType.Filename)
                 {
                 {
-                    return result;
+                    var filename = Path.GetFileNameWithoutExtension(pathSpan);
+
+                    if (filename.Equals(rule.Token, StringComparison.OrdinalIgnoreCase))
+                    {
+                        result.ExtraType = rule.ExtraType;
+                        result.Rule = rule;
+                    }
                 }
                 }
-            }
+                else if (rule.RuleType == ExtraRuleType.Suffix)
+                {
+                    // Trim the digits from the end of the filename so we can recognize things like -trailer2
+                    var filename = Path.GetFileNameWithoutExtension(pathSpan).TrimEnd(_digits);
 
 
-            if (rule.RuleType == ExtraRuleType.Filename)
-            {
-                var filename = Path.GetFileNameWithoutExtension(path);
+                    if (filename.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase))
+                    {
+                        result.ExtraType = rule.ExtraType;
+                        result.Rule = rule;
+                    }
+                }
+                else if (rule.RuleType == ExtraRuleType.Regex)
+                {
+                    var filename = Path.GetFileName(path);
+
+                    var isMatch = Regex.IsMatch(filename, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled);
 
 
-                if (string.Equals(filename, rule.Token, StringComparison.OrdinalIgnoreCase))
+                    if (isMatch)
+                    {
+                        result.ExtraType = rule.ExtraType;
+                        result.Rule = rule;
+                    }
+                }
+                else if (rule.RuleType == ExtraRuleType.DirectoryName)
                 {
                 {
-                    result.ExtraType = rule.ExtraType;
-                    result.Rule = rule;
+                    var directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan));
+                    if (directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase))
+                    {
+                        result.ExtraType = rule.ExtraType;
+                        result.Rule = rule;
+                    }
                 }
                 }
-            }
-            else if (rule.RuleType == ExtraRuleType.Suffix)
-            {
-                var filename = Path.GetFileNameWithoutExtension(path);
 
 
-                if (filename.IndexOf(rule.Token, StringComparison.OrdinalIgnoreCase) > 0)
+                if (result.ExtraType != null)
                 {
                 {
-                    result.ExtraType = rule.ExtraType;
-                    result.Rule = rule;
+                    return result;
                 }
                 }
             }
             }
-            else if (rule.RuleType == ExtraRuleType.Regex)
+
+            return result;
+        }
+
+        /// <summary>
+        /// Finds extras matching the video info.
+        /// </summary>
+        /// <param name="files">The list of file video infos.</param>
+        /// <param name="videoInfo">The video to compare against.</param>
+        /// <param name="videoFlagDelimiters">The video flag delimiters.</param>
+        /// <returns>A list of video extras for [videoInfo].</returns>
+        public static IReadOnlyList<VideoFileInfo> GetExtras(IReadOnlyList<VideoInfo> files, VideoFileInfo videoInfo, ReadOnlySpan<char> videoFlagDelimiters)
+        {
+            var parentDir = videoInfo.IsDirectory ? videoInfo.Path : Path.GetDirectoryName(videoInfo.Path.AsSpan());
+
+            var trimmedFileNameWithoutExtension = TrimFilenameDelimiters(videoInfo.FileNameWithoutExtension, videoFlagDelimiters);
+            var trimmedVideoInfoName = TrimFilenameDelimiters(videoInfo.Name, videoFlagDelimiters);
+
+            var result = new List<VideoFileInfo>();
+            for (var pos = files.Count - 1; pos >= 0; pos--)
             {
             {
-                var filename = Path.GetFileName(path);
+                var current = files[pos];
+                // ignore non-extras and multi-file (can this happen?)
+                if (current.ExtraType == null || current.Files.Count > 1)
+                {
+                    continue;
+                }
+
+                var currentFile = current.Files[0];
+                var trimmedCurrentFileName = TrimFilenameDelimiters(currentFile.Name, videoFlagDelimiters);
 
 
-                var regex = new Regex(rule.Token, RegexOptions.IgnoreCase);
+                // first check filenames
+                bool isValid = StartsWith(trimmedCurrentFileName, trimmedFileNameWithoutExtension)
+                               || (StartsWith(trimmedCurrentFileName, trimmedVideoInfoName) && currentFile.Year == videoInfo.Year);
 
 
-                if (regex.IsMatch(filename))
+                // then by directory
+                if (!isValid)
                 {
                 {
-                    result.ExtraType = rule.ExtraType;
-                    result.Rule = rule;
+                    // When the extra rule type is DirectoryName we must go one level higher to get the "real" dir name
+                    var currentParentDir = currentFile.ExtraRule?.RuleType == ExtraRuleType.DirectoryName
+                        ? Path.GetDirectoryName(Path.GetDirectoryName(currentFile.Path.AsSpan()))
+                        : Path.GetDirectoryName(currentFile.Path.AsSpan());
+
+                    isValid = !currentParentDir.IsEmpty && !parentDir.IsEmpty && currentParentDir.Equals(parentDir, StringComparison.OrdinalIgnoreCase);
                 }
                 }
-            }
-            else if (rule.RuleType == ExtraRuleType.DirectoryName)
-            {
-                var directoryName = Path.GetFileName(Path.GetDirectoryName(path));
-                if (string.Equals(directoryName, rule.Token, StringComparison.OrdinalIgnoreCase))
+
+                if (isValid)
                 {
                 {
-                    result.ExtraType = rule.ExtraType;
-                    result.Rule = rule;
+                    result.Add(currentFile);
                 }
                 }
             }
             }
 
 
-            return result;
+            return result.OrderBy(r => r.Path).ToArray();
+        }
+
+        private static ReadOnlySpan<char> TrimFilenameDelimiters(ReadOnlySpan<char> name, ReadOnlySpan<char> videoFlagDelimiters)
+        {
+            return name.IsEmpty ? name : name.TrimEnd().TrimEnd(videoFlagDelimiters).TrimEnd();
+        }
+
+        private static bool StartsWith(ReadOnlySpan<char> fileName, ReadOnlySpan<char> baseName)
+        {
+            return !baseName.IsEmpty && fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase);
         }
         }
     }
     }
 }
 }

+ 17 - 12
Emby.Naming/Video/FileStack.cs

@@ -1,6 +1,6 @@
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
-using System.Linq;
+using Jellyfin.Extensions;
 
 
 namespace Emby.Naming.Video
 namespace Emby.Naming.Video
 {
 {
@@ -12,25 +12,30 @@ namespace Emby.Naming.Video
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="FileStack"/> class.
         /// Initializes a new instance of the <see cref="FileStack"/> class.
         /// </summary>
         /// </summary>
-        public FileStack()
+        /// <param name="name">The stack name.</param>
+        /// <param name="isDirectory">Whether the stack files are directories.</param>
+        /// <param name="files">The stack files.</param>
+        public FileStack(string name, bool isDirectory, IReadOnlyList<string> files)
         {
         {
-            Files = new List<string>();
+            Name = name;
+            IsDirectoryStack = isDirectory;
+            Files = files;
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Gets or sets name of file stack.
+        /// Gets the name of file stack.
         /// </summary>
         /// </summary>
-        public string Name { get; set; } = string.Empty;
+        public string Name { get; }
 
 
         /// <summary>
         /// <summary>
-        /// Gets or sets list of paths in stack.
+        /// Gets the list of paths in stack.
         /// </summary>
         /// </summary>
-        public List<string> Files { get; set; }
+        public IReadOnlyList<string> Files { get; }
 
 
         /// <summary>
         /// <summary>
-        /// Gets or sets a value indicating whether stack is directory stack.
+        /// Gets a value indicating whether stack is directory stack.
         /// </summary>
         /// </summary>
-        public bool IsDirectoryStack { get; set; }
+        public bool IsDirectoryStack { get; }
 
 
         /// <summary>
         /// <summary>
         /// Helper function to determine if path is in the stack.
         /// Helper function to determine if path is in the stack.
@@ -40,12 +45,12 @@ namespace Emby.Naming.Video
         /// <returns>True if file is in the stack.</returns>
         /// <returns>True if file is in the stack.</returns>
         public bool ContainsFile(string file, bool isDirectory)
         public bool ContainsFile(string file, bool isDirectory)
         {
         {
-            if (IsDirectoryStack == isDirectory)
+            if (string.IsNullOrEmpty(file))
             {
             {
-                return Files.Contains(file, StringComparer.OrdinalIgnoreCase);
+                return false;
             }
             }
 
 
-            return false;
+            return IsDirectoryStack == isDirectory && Files.Contains(file, StringComparison.OrdinalIgnoreCase);
         }
         }
     }
     }
 }
 }

+ 48 - 0
Emby.Naming/Video/FileStackRule.cs

@@ -0,0 +1,48 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+
+namespace Emby.Naming.Video;
+
+/// <summary>
+/// Regex based rule for file stacking (eg. disc1, disc2).
+/// </summary>
+public class FileStackRule
+{
+    private readonly Regex _tokenRegex;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="FileStackRule"/> class.
+    /// </summary>
+    /// <param name="token">Token.</param>
+    /// <param name="isNumerical">Whether the file stack rule uses numerical or alphabetical numbering.</param>
+    public FileStackRule(string token, bool isNumerical)
+    {
+        _tokenRegex = new Regex(token, RegexOptions.IgnoreCase);
+        IsNumerical = isNumerical;
+    }
+
+    /// <summary>
+    /// Gets a value indicating whether the rule uses numerical or alphabetical numbering.
+    /// </summary>
+    public bool IsNumerical { get; }
+
+    /// <summary>
+    /// Match the input against the rule regex.
+    /// </summary>
+    /// <param name="input">The input.</param>
+    /// <param name="result">The part type and number or <c>null</c>.</param>
+    /// <returns>A value indicating whether the input matched the rule.</returns>
+    public bool Match(string input, [NotNullWhen(true)] out (string StackName, string PartType, string PartNumber)? result)
+    {
+        result = null;
+        var match = _tokenRegex.Match(input);
+        if (!match.Success)
+        {
+            return false;
+        }
+
+        var partType = match.Groups["parttype"].Success ? match.Groups["parttype"].Value : "unknown";
+        result = (match.Groups["filename"].Value, partType, match.Groups["number"].Value);
+        return true;
+    }
+}

+ 0 - 53
Emby.Naming/Video/FlagParser.cs

@@ -1,53 +0,0 @@
-using System;
-using System.IO;
-using Emby.Naming.Common;
-
-namespace Emby.Naming.Video
-{
-    /// <summary>
-    /// Parses list of flags from filename based on delimiters.
-    /// </summary>
-    public class FlagParser
-    {
-        private readonly NamingOptions _options;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="FlagParser"/> class.
-        /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing VideoFlagDelimiters.</param>
-        public FlagParser(NamingOptions options)
-        {
-            _options = options;
-        }
-
-        /// <summary>
-        /// Calls GetFlags function with _options.VideoFlagDelimiters parameter.
-        /// </summary>
-        /// <param name="path">Path to file.</param>
-        /// <returns>List of found flags.</returns>
-        public string[] GetFlags(string path)
-        {
-            return GetFlags(path, _options.VideoFlagDelimiters);
-        }
-
-        /// <summary>
-        /// Parses flags from filename based on delimiters.
-        /// </summary>
-        /// <param name="path">Path to file.</param>
-        /// <param name="delimiters">Delimiters used to extract flags.</param>
-        /// <returns>List of found flags.</returns>
-        public string[] GetFlags(string path, char[] delimiters)
-        {
-            if (string.IsNullOrEmpty(path))
-            {
-                return Array.Empty<string>();
-            }
-
-            // Note: the tags need be be surrounded be either a space ( ), hyphen -, dot . or underscore _.
-
-            var file = Path.GetFileName(path);
-
-            return file.Split(delimiters, StringSplitOptions.RemoveEmptyEntries);
-        }
-    }
-}

+ 36 - 52
Emby.Naming/Video/Format3DParser.cs

@@ -1,45 +1,37 @@
 using System;
 using System;
-using System.Linq;
 using Emby.Naming.Common;
 using Emby.Naming.Common;
 
 
 namespace Emby.Naming.Video
 namespace Emby.Naming.Video
 {
 {
     /// <summary>
     /// <summary>
-    /// Parste 3D format related flags.
+    /// Parse 3D format related flags.
     /// </summary>
     /// </summary>
-    public class Format3DParser
+    public static class Format3DParser
     {
     {
-        private readonly NamingOptions _options;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="Format3DParser"/> class.
-        /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing VideoFlagDelimiters and passes options to <see cref="FlagParser"/>.</param>
-        public Format3DParser(NamingOptions options)
-        {
-            _options = options;
-        }
+        // Static default result to save on allocation costs.
+        private static readonly Format3DResult _defaultResult = new (false, null);
 
 
         /// <summary>
         /// <summary>
         /// Parse 3D format related flags.
         /// Parse 3D format related flags.
         /// </summary>
         /// </summary>
         /// <param name="path">Path to file.</param>
         /// <param name="path">Path to file.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Returns <see cref="Format3DResult"/> object.</returns>
         /// <returns>Returns <see cref="Format3DResult"/> object.</returns>
-        public Format3DResult Parse(string path)
+        public static Format3DResult Parse(ReadOnlySpan<char> path, NamingOptions namingOptions)
         {
         {
-            int oldLen = _options.VideoFlagDelimiters.Length;
-            var delimiters = new char[oldLen + 1];
-            _options.VideoFlagDelimiters.CopyTo(delimiters, 0);
+            int oldLen = namingOptions.VideoFlagDelimiters.Length;
+            Span<char> delimiters = stackalloc char[oldLen + 1];
+            namingOptions.VideoFlagDelimiters.AsSpan().CopyTo(delimiters);
             delimiters[oldLen] = ' ';
             delimiters[oldLen] = ' ';
 
 
-            return Parse(new FlagParser(_options).GetFlags(path, delimiters));
+            return Parse(path, delimiters, namingOptions);
         }
         }
 
 
-        internal Format3DResult Parse(string[] videoFlags)
+        private static Format3DResult Parse(ReadOnlySpan<char> path, ReadOnlySpan<char> delimiters, NamingOptions namingOptions)
         {
         {
-            foreach (var rule in _options.Format3DRules)
+            foreach (var rule in namingOptions.Format3DRules)
             {
             {
-                var result = Parse(videoFlags, rule);
+                var result = Parse(path, rule, delimiters);
 
 
                 if (result.Is3D)
                 if (result.Is3D)
                 {
                 {
@@ -47,51 +39,43 @@ namespace Emby.Naming.Video
                 }
                 }
             }
             }
 
 
-            return new Format3DResult();
+            return _defaultResult;
         }
         }
 
 
-        private static Format3DResult Parse(string[] videoFlags, Format3DRule rule)
+        private static Format3DResult Parse(ReadOnlySpan<char> path, Format3DRule rule, ReadOnlySpan<char> delimiters)
         {
         {
-            var result = new Format3DResult();
+            bool is3D = false;
+            string? format3D = null;
 
 
-            if (string.IsNullOrEmpty(rule.PrecedingToken))
+            // If there's no preceding token we just consider it found
+            var foundPrefix = string.IsNullOrEmpty(rule.PrecedingToken);
+            while (path.Length > 0)
             {
             {
-                result.Format3D = new[] { rule.Token }.FirstOrDefault(i => videoFlags.Contains(i, StringComparer.OrdinalIgnoreCase));
-                result.Is3D = !string.IsNullOrEmpty(result.Format3D);
-
-                if (result.Is3D)
+                var index = path.IndexOfAny(delimiters);
+                if (index == -1)
                 {
                 {
-                    result.Tokens.Add(rule.Token);
+                    index = path.Length - 1;
                 }
                 }
-            }
-            else
-            {
-                var foundPrefix = false;
-                string? format = null;
 
 
-                foreach (var flag in videoFlags)
-                {
-                    if (foundPrefix)
-                    {
-                        result.Tokens.Add(rule.PrecedingToken);
+                var currentSlice = path[..index];
+                path = path[(index + 1)..];
 
 
-                        if (string.Equals(rule.Token, flag, StringComparison.OrdinalIgnoreCase))
-                        {
-                            format = flag;
-                            result.Tokens.Add(rule.Token);
-                        }
+                if (!foundPrefix)
+                {
+                    foundPrefix = currentSlice.Equals(rule.PrecedingToken, StringComparison.OrdinalIgnoreCase);
+                    continue;
+                }
 
 
-                        break;
-                    }
+                is3D = foundPrefix && currentSlice.Equals(rule.Token, StringComparison.OrdinalIgnoreCase);
 
 
-                    foundPrefix = string.Equals(flag, rule.PrecedingToken, StringComparison.OrdinalIgnoreCase);
+                if (is3D)
+                {
+                    format3D = rule.Token;
+                    break;
                 }
                 }
-
-                result.Is3D = foundPrefix && !string.IsNullOrEmpty(format);
-                result.Format3D = format;
             }
             }
 
 
-            return result;
+            return is3D ? new Format3DResult(true, format3D) : _defaultResult;
         }
         }
     }
     }
 }
 }

+ 9 - 14
Emby.Naming/Video/Format3DResult.cs

@@ -1,5 +1,3 @@
-using System.Collections.Generic;
-
 namespace Emby.Naming.Video
 namespace Emby.Naming.Video
 {
 {
     /// <summary>
     /// <summary>
@@ -10,27 +8,24 @@ namespace Emby.Naming.Video
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="Format3DResult"/> class.
         /// Initializes a new instance of the <see cref="Format3DResult"/> class.
         /// </summary>
         /// </summary>
-        public Format3DResult()
+        /// <param name="is3D">A value indicating whether the parsed string contains 3D tokens.</param>
+        /// <param name="format3D">The 3D format. Value might be null if [is3D] is <c>false</c>.</param>
+        public Format3DResult(bool is3D, string? format3D)
         {
         {
-            Tokens = new List<string>();
+            Is3D = is3D;
+            Format3D = format3D;
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Gets or sets a value indicating whether [is3 d].
+        /// Gets a value indicating whether [is3 d].
         /// </summary>
         /// </summary>
         /// <value><c>true</c> if [is3 d]; otherwise, <c>false</c>.</value>
         /// <value><c>true</c> if [is3 d]; otherwise, <c>false</c>.</value>
-        public bool Is3D { get; set; }
+        public bool Is3D { get; }
 
 
         /// <summary>
         /// <summary>
-        /// Gets or sets the format3 d.
+        /// Gets the format3 d.
         /// </summary>
         /// </summary>
         /// <value>The format3 d.</value>
         /// <value>The format3 d.</value>
-        public string? Format3D { get; set; }
-
-        /// <summary>
-        /// Gets or sets the tokens.
-        /// </summary>
-        /// <value>The tokens.</value>
-        public List<string> Tokens { get; set; }
+        public string? Format3D { get; }
     }
     }
 }
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است