Pārlūkot izejas kodu

Merge branch 'master' into comparisons

Cody Robibero 3 gadi atpakaļ
vecāks
revīzija
634ce40c2f
100 mainītis faili ar 2289 papildinājumiem un 2690 dzēšanām
  1. 1 1
      .ci/azure-pipelines-abi.yml
  2. 8 1
      .ci/azure-pipelines-main.yml
  3. 25 3
      .ci/azure-pipelines-package.yml
  4. 2 2
      .ci/azure-pipelines-test.yml
  5. 3 2
      .ci/azure-pipelines.yml
  6. 1 0
      .copr
  7. 0 1
      .copr/Makefile
  8. 0 49
      .github/ISSUE_TEMPLATE/bug_report.md
  9. 106 0
      .github/ISSUE_TEMPLATE/issue report.yml
  10. 5 1
      .github/stale.yml
  11. 7 5
      .github/workflows/automation.yml
  12. 2 1
      .github/workflows/codeql-analysis.yml
  13. 1 1
      .github/workflows/commands.yml
  14. 124 0
      .github/workflows/openapi.yml
  15. 3 0
      .gitignore
  16. 2 2
      .vscode/launch.json
  17. 5 0
      CONTRIBUTORS.md
  18. 17 0
      Directory.Build.props
  19. 31 19
      Dockerfile
  20. 26 19
      Dockerfile.arm
  21. 26 17
      Dockerfile.arm64
  22. 3 2
      DvdLib/DvdLib.csproj
  23. 2 1
      DvdLib/Ifo/Dvd.cs
  24. 1 3
      Emby.Dlna/Configuration/DlnaOptions.cs
  25. 1 3
      Emby.Dlna/ContentDirectory/ContentDirectoryService.cs
  26. 251 584
      Emby.Dlna/ContentDirectory/ControlHandler.cs
  27. 11 8
      Emby.Dlna/ContentDirectory/ServerItem.cs
  28. 3 3
      Emby.Dlna/ControlResponse.cs
  29. 15 17
      Emby.Dlna/Didl/DidlBuilder.cs
  30. 56 57
      Emby.Dlna/DlnaManager.cs
  31. 2 8
      Emby.Dlna/Emby.Dlna.csproj
  32. 3 3
      Emby.Dlna/EventSubscriptionResponse.cs
  33. 7 22
      Emby.Dlna/Eventing/DlnaEventManager.cs
  34. 12 23
      Emby.Dlna/Main/DlnaEntryPoint.cs
  35. 9 24
      Emby.Dlna/PlayTo/Device.cs
  36. 7 3
      Emby.Dlna/PlayTo/MediaChangedEventArgs.cs
  37. 3 5
      Emby.Dlna/PlayTo/PlayToController.cs
  38. 4 5
      Emby.Dlna/PlayTo/PlayToManager.cs
  39. 5 2
      Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs
  40. 5 2
      Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs
  41. 5 2
      Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs
  42. 9 7
      Emby.Dlna/PlayTo/SsdpHttpClient.cs
  43. 3 5
      Emby.Dlna/Server/DescriptionXmlBuilder.cs
  44. 3 7
      Emby.Dlna/Service/BaseControlHandler.cs
  45. 4 4
      Emby.Dlna/Service/BaseService.cs
  46. 1 5
      Emby.Dlna/Service/ControlErrorHandler.cs
  47. 1 7
      Emby.Drawing/Emby.Drawing.csproj
  48. 2 2
      Emby.Drawing/ImageProcessor.cs
  49. 1 1
      Emby.Naming/Audio/AudioFileParser.cs
  50. 4 4
      Emby.Naming/AudioBook/AudioBookInfo.cs
  51. 6 6
      Emby.Naming/AudioBook/AudioBookListResolver.cs
  52. 2 2
      Emby.Naming/AudioBook/AudioBookResolver.cs
  53. 75 21
      Emby.Naming/Common/NamingOptions.cs
  54. 3 9
      Emby.Naming/Emby.Naming.csproj
  55. 6 5
      Emby.Naming/Subtitles/SubtitleParser.cs
  56. 2 2
      Emby.Naming/TV/EpisodeResolver.cs
  57. 29 0
      Emby.Naming/TV/SeriesInfo.cs
  58. 60 0
      Emby.Naming/TV/SeriesPathParser.cs
  59. 19 0
      Emby.Naming/TV/SeriesPathParserResult.cs
  60. 49 0
      Emby.Naming/TV/SeriesResolver.cs
  61. 13 13
      Emby.Naming/Video/CleanStringParser.cs
  62. 76 30
      Emby.Naming/Video/ExtraResolver.cs
  63. 17 12
      Emby.Naming/Video/FileStack.cs
  64. 48 0
      Emby.Naming/Video/FileStackRule.cs
  65. 68 151
      Emby.Naming/Video/StackResolver.cs
  66. 2 2
      Emby.Naming/Video/StubResolver.cs
  67. 6 7
      Emby.Naming/Video/VideoInfo.cs
  68. 30 144
      Emby.Naming/Video/VideoListResolver.cs
  69. 8 7
      Emby.Naming/Video/VideoResolver.cs
  70. 1 5
      Emby.Notifications/Emby.Notifications.csproj
  71. 2 22
      Emby.Notifications/NotificationEntryPoint.cs
  72. 1 5
      Emby.Photos/Emby.Photos.csproj
  73. 2 1
      Emby.Photos/PhotoProvider.cs
  74. 1 1
      Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs
  75. 4 6
      Emby.Server.Implementations/AppBase/ConfigurationHelper.cs
  76. 236 379
      Emby.Server.Implementations/ApplicationHost.cs
  77. 1 112
      Emby.Server.Implementations/Archiving/ZipClient.cs
  78. 11 20
      Emby.Server.Implementations/Channels/ChannelManager.cs
  79. 2 1
      Emby.Server.Implementations/Channels/ChannelPostScanTask.cs
  80. 20 28
      Emby.Server.Implementations/Collections/CollectionManager.cs
  81. 52 74
      Emby.Server.Implementations/Cryptography/CryptographyProvider.cs
  82. 4 55
      Emby.Server.Implementations/Data/BaseSqliteRepository.cs
  83. 4 2
      Emby.Server.Implementations/Data/ManagedConnection.cs
  84. 3 3
      Emby.Server.Implementations/Data/SqliteExtensions.cs
  85. 399 309
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  86. 31 29
      Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
  87. 30 0
      Emby.Server.Implementations/Data/SynchronouseMode.cs
  88. 23 0
      Emby.Server.Implementations/Data/TempStoreMode.cs
  89. 10 11
      Emby.Server.Implementations/Devices/DeviceId.cs
  90. 0 146
      Emby.Server.Implementations/Devices/DeviceManager.cs
  91. 46 72
      Emby.Server.Implementations/Dto/DtoService.cs
  92. 11 14
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  93. 1 7
      Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs
  94. 3 3
      Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
  95. 3 0
      Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
  96. 4 4
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  97. 14 7
      Emby.Server.Implementations/HttpServer/Security/SessionContext.cs
  98. 6 6
      Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
  99. 6 1
      Emby.Server.Implementations/HttpServer/WebSocketManager.cs
  100. 11 13
      Emby.Server.Implementations/IO/FileRefresher.cs

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

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

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

@@ -1,7 +1,7 @@
 parameters:
   LinuxImage: 'ubuntu-latest'
   RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj'
-  DotNetSdkVersion: 5.0.103
+  DotNetSdkVersion: 6.0.x
 
 jobs:
   - job: Build
@@ -91,3 +91,10 @@ jobs:
         inputs:
           targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/MediaBrowser.Common.dll'
           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'

+ 25 - 3
.ci/azure-pipelines-package.yml

@@ -39,6 +39,14 @@ jobs:
     vmImage: 'ubuntu-latest'
 
   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'
     displayName: 'Build Dockerfile'
 
@@ -80,6 +88,14 @@ jobs:
     vmImage: 'ubuntu-latest'
 
   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
     displayName: 'Download OpenAPI Spec'
     inputs:
@@ -127,6 +143,10 @@ jobs:
     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: Docker@2
     displayName: 'Push Unstable Image'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
@@ -181,7 +201,7 @@ jobs:
     inputs:
       sshEndpoint: repository
       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
   displayName: 'Publish NuGet packages'
@@ -195,10 +215,10 @@ jobs:
 
   steps:
   - task: UseDotNet@2
-    displayName: 'Use .NET 5.0 sdk'
+    displayName: 'Use .NET 6.0 sdk'
     inputs:
       packageType: 'sdk'
-      version: '5.0.x'
+      version: '6.0.x'
 
   - task: DotNetCoreCLI@2
     displayName: 'Build Stable Nuget packages'
@@ -211,6 +231,7 @@ jobs:
         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)
 
@@ -225,6 +246,7 @@ jobs:
         MediaBrowser.Controller/MediaBrowser.Controller.csproj
         MediaBrowser.Model/MediaBrowser.Model.csproj
         Emby.Naming/Emby.Naming.csproj
+        src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
       custom: 'pack'
       arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory) -p:Stability=Unstable'
 

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

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

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

@@ -5,8 +5,6 @@ variables:
   value: 'tests/**/*Tests.csproj'
 - name: RestoreBuildProjects
   value: 'Jellyfin.Server/Jellyfin.Server.csproj'
-- name: DotNetSdkVersion
-  value: 5.0.103
 
 pr:
   autoCancel: true
@@ -57,6 +55,9 @@ jobs:
         Common:
           NugetPackageName: Jellyfin.Common
           AssemblyFileName: MediaBrowser.Common.dll
+        Extensions:
+          NugetPackageName: Jellyfin.Extensions
+          AssemblyFileName: Jellyfin.Extensions.dll
       LinuxImage: 'ubuntu-latest'
 
 - ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}:

+ 1 - 0
.copr

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

+ 0 - 1
.copr/Makefile

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

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

@@ -1,49 +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. -->
-
-**Server Logs**
-<!-- Please paste any log errors. -->
-
-**FFmpeg Logs**
-<!-- Please paste any log errors. -->
-
-**Browser Console 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

+ 5 - 1
.github/stale.yml

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

+ 7 - 5
.github/workflows/automation.yml

@@ -11,6 +11,7 @@ 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
@@ -22,9 +23,10 @@ jobs:
   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.7.1
+        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:
@@ -33,7 +35,7 @@ jobs:
           repo-token: ${{ secrets.JF_BOT_TOKEN }}
 
       - name: Add to 'Release Next' project
-        uses: alex-page/github-project-automation-plus@v0.7.1
+        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:
@@ -42,7 +44,7 @@ jobs:
           repo-token: ${{ secrets.JF_BOT_TOKEN }}
 
       - name: Add to 'Current Release' project
-        uses: alex-page/github-project-automation-plus@v0.7.1
+        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:
@@ -56,7 +58,7 @@ jobs:
         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.7.1
+        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:
@@ -65,7 +67,7 @@ jobs:
           repo-token: ${{ secrets.JF_BOT_TOKEN }}
 
       - name: Add issue to triage project
-        uses: alex-page/github-project-automation-plus@v0.7.1
+        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:

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

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

+ 1 - 1
.github/workflows/commands.yml

@@ -29,7 +29,7 @@ jobs:
           fetch-depth: 0
 
       - name: Automatic Rebase
-        uses: cirrus-actions/rebase@1.4
+        uses: cirrus-actions/rebase@1.5
         env:
           GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
 

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

+ 3 - 0
.gitignore

@@ -278,3 +278,6 @@ web/
 web-src.*
 MediaBrowser.WebDashboard/jellyfin-web
 apiclient/generated
+
+# Omnisharp crash logs
+mono_crash.*.json

+ 2 - 2
.vscode/launch.json

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

+ 5 - 0
CONTRIBUTORS.md

@@ -46,6 +46,7 @@
  - [fruhnow](https://github.com/fruhnow)
  - [geilername](https://github.com/geilername)
  - [gnattu](https://github.com/gnattu)
+ - [GodTamIt](https://github.com/GodTamIt)
  - [grafixeyehero](https://github.com/grafixeyehero)
  - [h1nk](https://github.com/h1nk)
  - [hawken93](https://github.com/hawken93)
@@ -148,6 +149,8 @@
  - [skyfrk](https://github.com/skyfrk)
  - [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
 
@@ -212,3 +215,5 @@
  - [Tim Hobbs](https://github.com/timhobbs)
  - [SvenVandenbrande](https://github.com/SvenVandenbrande)
  - [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>

+ 31 - 19
Dockerfile

@@ -1,4 +1,8 @@
-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:lts-alpine as web-builder
 ARG JELLYFIN_WEB_VERSION=master
@@ -8,15 +12,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-
  && npm ci --no-audit --unsafe-perm \
  && mv dist /dist
 
-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 debian:buster-slim
+FROM debian:stable-slim as app
 
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 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)
 ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 
-COPY --from=builder /jellyfin /jellyfin
-COPY --from=web-builder /dist /jellyfin/jellyfin-web
-
 # https://github.com/intel/compute-runtime/releases
-ARG GMMLIB_VERSION=20.3.2
-ARG IGC_VERSION=1.0.5435
-ARG NEO_VERSION=20.46.18421
-ARG LEVEL_ZERO_VERSION=1.0.18421
+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:
 # mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding.
+# curl: healthcheck
 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 - \
  && 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 \
@@ -68,14 +62,32 @@ RUN apt-get update \
  && chmod 777 /cache /config /media \
  && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen
 
-ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
+# ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
 ENV LC_ALL en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANGUAGE en_US:en
 
+FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
+WORKDIR /repo
+COPY . .
+ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
+# 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
 VOLUME /cache /config /media
 ENTRYPOINT ["./jellyfin/jellyfin", \
     "--datadir", "/config", \
     "--cachedir", "/cache", \
     "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"]
+
+HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \
+     CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1

+ 26 - 19
Dockerfile.arm

@@ -1,8 +1,8 @@
-# DESIGNED FOR BUILDING ON AMD64 ONLY
+# DESIGNED FOR BUILDING ON ARM ONLY
 #####################################
 # Requires binfm_misc registration
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
-ARG DOTNET_VERSION=5.0
+ARG DOTNET_VERSION=6.0
 
 
 FROM node:lts-alpine as web-builder
@@ -13,19 +13,8 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-
  && npm ci --no-audit --unsafe-perm \
  && 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 arm32v7/debian:buster-slim
+FROM arm32v7/debian:stable-slim as app
 
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 ARG DEBIAN_FRONTEND="noninteractive"
@@ -35,6 +24,8 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
 ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 
 COPY --from=qemu /usr/bin/qemu-arm-static /usr/bin
+
+# curl: setup & healthcheck
 RUN apt-get update \
  && 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 - && \
@@ -53,7 +44,7 @@ RUN apt-get update \
  vainfo \
  libva2 \
  locales \
- && apt-get remove curl gnupg -y \
+ && apt-get remove gnupg -y \
  && apt-get clean autoclean -y \
  && apt-get autoremove -y \
  && rm -rf /var/lib/apt/lists/* \
@@ -61,17 +52,33 @@ RUN apt-get update \
  && chmod 777 /cache /config /media \
  && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen
 
-COPY --from=builder /jellyfin /jellyfin
-COPY --from=web-builder /dist /jellyfin/jellyfin-web
-
-ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
+# ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
 ENV LC_ALL en_US.UTF-8
 ENV LANG en_US.UTF-8
 ENV LANGUAGE en_US:en
 
+FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
+WORKDIR /repo
+COPY . .
+ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
+# Discard objs - may cause failures if exists
+RUN find . -type d -name obj | xargs -r rm -r
+# Build
+RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm "-p:DebugSymbols=false;DebugType=none"
+
+FROM app
+
+ENV HEALTHCHECK_URL=http://localhost:8096/health
+
+COPY --from=builder /jellyfin /jellyfin
+COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
 EXPOSE 8096
 VOLUME /cache /config /media
 ENTRYPOINT ["./jellyfin/jellyfin", \
     "--datadir", "/config", \
     "--cachedir", "/cache", \
     "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"]
+
+HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \
+     CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1

+ 26 - 17
Dockerfile.arm64

@@ -1,8 +1,8 @@
-# DESIGNED FOR BUILDING ON AMD64 ONLY
+# DESIGNED FOR BUILDING ON ARM64 ONLY
 #####################################
 # Requires binfm_misc registration
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
-ARG DOTNET_VERSION=5.0
+ARG DOTNET_VERSION=6.0
 
 
 FROM node:lts-alpine as web-builder
@@ -13,18 +13,8 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-
  && npm ci --no-audit --unsafe-perm \
  && 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 arm64v8/debian:buster-slim
+FROM arm64v8/debian:stable-slim as app
 
 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
 ARG DEBIAN_FRONTEND="noninteractive"
@@ -34,6 +24,8 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
 ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 
 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 \
  ffmpeg \
  libssl-dev \
@@ -43,6 +35,7 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge
  libomxil-bellagio0 \
  libomxil-bellagio-bin \
  locales \
+ curl \
  && apt-get clean autoclean -y \
  && apt-get autoremove -y \
  && 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 \
  && 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 LANG en_US.UTF-8
 ENV LANGUAGE en_US:en
 
+FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
+WORKDIR /repo
+COPY . .
+ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
+# Discard objs - may cause failures if exists
+RUN find . -type d -name obj | xargs -r rm -r
+# Build
+RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 "-p:DebugSymbols=false;DebugType=none"
+
+FROM app
+
+ENV HEALTHCHECK_URL=http://localhost:8096/health
+
+COPY --from=builder /jellyfin /jellyfin
+COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
 EXPOSE 8096
 VOLUME /cache /config /media
 ENTRYPOINT ["./jellyfin/jellyfin", \
     "--datadir", "/config", \
     "--cachedir", "/cache", \
     "--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>
 
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+    <AnalysisMode>AllDisabledByDefault</AnalysisMode>
+    <Nullable>disable</Nullable>
   </PropertyGroup>
 
 </Project>

+ 2 - 1
DvdLib/Ifo/Dvd.cs

@@ -2,6 +2,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.Globalization;
 using System.IO;
 using System.Linq;
 
@@ -76,7 +77,7 @@ namespace DvdLib.Ifo
 
         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)) ??
                 allFiles.FirstOrDefault(i => string.Equals(i.Name, Path.ChangeExtension(filename, ".bup"), StringComparison.OrdinalIgnoreCase));

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

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

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

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -140,7 +138,7 @@ namespace Emby.Dlna.ContentDirectory
         /// </summary>
         /// <param name="profile">The <see cref="DeviceProfile"/>.</param>
         /// <returns>The <see cref="User"/>.</returns>
-        private User GetUser(DeviceProfile profile)
+        private User? GetUser(DeviceProfile profile)
         {
             if (!string.IsNullOrEmpty(profile.UserId))
             {

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 251 - 584
Emby.Dlna/ContentDirectory/ControlHandler.cs


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

@@ -1,5 +1,3 @@
-#pragma warning disable CS1591
-
 using MediaBrowser.Controller.Entities;
 
 namespace Emby.Dlna.ContentDirectory
@@ -13,24 +11,29 @@ namespace Emby.Dlna.ContentDirectory
         /// Initializes a new instance of the <see cref="ServerItem"/> class.
         /// </summary>
         /// <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;
 
-            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;
             }
         }
 
         /// <summary>
-        /// Gets or sets the underlying base item.
+        /// Gets the underlying base item.
         /// </summary>
-        public BaseItem Item { get; set; }
+        public BaseItem Item { get; }
 
         /// <summary>
-        /// Gets or sets the DLNA item type.
+        /// Gets the DLNA item type.
         /// </summary>
-        public StubType? StubType { get; set; }
+        public StubType? StubType { get; }
     }
 }

+ 3 - 3
Emby.Dlna/ControlResponse.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System.Collections.Generic;
@@ -8,9 +6,11 @@ namespace Emby.Dlna
 {
     public class ControlResponse
     {
-        public ControlResponse()
+        public ControlResponse(string xml, bool isSuccessful)
         {
             Headers = new Dictionary<string, string>();
+            Xml = xml;
+            IsSuccessful = isSuccessful;
         }
 
         public IDictionary<string, string> Headers { get; }

+ 15 - 17
Emby.Dlna/Didl/DidlBuilder.cs

@@ -41,8 +41,6 @@ namespace Emby.Dlna.Didl
         private const string NsUpnp = "urn:schemas-upnp-org:metadata-1-0/upnp/";
         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 IImageProcessor _imageProcessor;
         private readonly string _serverAddress;
@@ -317,7 +315,7 @@ namespace Emby.Dlna.Didl
 
             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"))
@@ -328,7 +326,7 @@ namespace Emby.Dlna.Didl
 
                     if (size.HasValue)
                     {
-                        writer.WriteAttributeString("size", size.Value.ToString(_usCulture));
+                        writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture));
                     }
                 }
             }
@@ -342,7 +340,7 @@ namespace Emby.Dlna.Didl
 
             if (targetChannels.HasValue)
             {
-                writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(_usCulture));
+                writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture));
             }
 
             if (filter.Contains("res@resolution"))
@@ -361,12 +359,12 @@ namespace Emby.Dlna.Didl
 
             if (targetSampleRate.HasValue)
             {
-                writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(_usCulture));
+                writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture));
             }
 
             if (totalBitrate.HasValue)
             {
-                writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(_usCulture));
+                writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(CultureInfo.InvariantCulture));
             }
 
             var mediaProfile = _profile.GetVideoMediaProfile(
@@ -552,7 +550,7 @@ namespace Emby.Dlna.Didl
 
             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"))
@@ -563,7 +561,7 @@ namespace Emby.Dlna.Didl
 
                     if (size.HasValue)
                     {
-                        writer.WriteAttributeString("size", size.Value.ToString(_usCulture));
+                        writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture));
                     }
                 }
             }
@@ -575,17 +573,17 @@ namespace Emby.Dlna.Didl
 
             if (targetChannels.HasValue)
             {
-                writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(_usCulture));
+                writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture));
             }
 
             if (targetSampleRate.HasValue)
             {
-                writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(_usCulture));
+                writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture));
             }
 
             if (targetAudioBitrate.HasValue)
             {
-                writer.WriteAttributeString("bitrate", targetAudioBitrate.Value.ToString(_usCulture));
+                writer.WriteAttributeString("bitrate", targetAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
             }
 
             var mediaProfile = _profile.GetAudioMediaProfile(
@@ -639,7 +637,7 @@ namespace Emby.Dlna.Didl
 
             writer.WriteAttributeString("restricted", "1");
             writer.WriteAttributeString("searchable", "1");
-            writer.WriteAttributeString("childCount", childCount.ToString(_usCulture));
+            writer.WriteAttributeString("childCount", childCount.ToString(CultureInfo.InvariantCulture));
 
             var clientId = GetClientId(folder, stubType);
 
@@ -731,7 +729,7 @@ namespace Emby.Dlna.Didl
             {
                 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);
                 }
             }
 
@@ -748,7 +746,7 @@ namespace Emby.Dlna.Didl
                 AddValue(writer, "upnp", "publisher", studio, NsUpnp);
             }
 
-            if (!(item is Folder))
+            if (item is not Folder)
             {
                 if (filter.Contains("dc:description"))
                 {
@@ -931,11 +929,11 @@ namespace Emby.Dlna.Didl
 
             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)
                 {
-                    AddValue(writer, "upnp", "episodeNumber", item.IndexNumber.Value.ToString(_usCulture), NsUpnp);
+                    AddValue(writer, "upnp", "episodeNumber", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), NsUpnp);
                 }
             }
         }

+ 56 - 57
Emby.Dlna/DlnaManager.cs

@@ -1,22 +1,18 @@
-#nullable disable
-
 #pragma warning disable CS1591
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Reflection;
-using System.Text;
 using System.Text.Json;
 using System.Text.RegularExpressions;
 using System.Threading.Tasks;
 using Emby.Dlna.Profiles;
 using Emby.Dlna.Server;
+using Jellyfin.Extensions.Json;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Json;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Drawing;
@@ -96,12 +92,14 @@ namespace Emby.Dlna
             }
         }
 
+        /// <inheritdoc />
         public DeviceProfile GetDefaultProfile()
         {
             return new DefaultProfile();
         }
 
-        public DeviceProfile GetProfile(DeviceIdentification deviceInfo)
+        /// <inheritdoc />
+        public DeviceProfile? GetProfile(DeviceIdentification deviceInfo)
         {
             if (deviceInfo == null)
             {
@@ -111,35 +109,18 @@ namespace Emby.Dlna
             var profile = GetProfiles()
                 .FirstOrDefault(i => i.Identification != null && IsMatch(deviceInfo, i.Identification));
 
-            if (profile != null)
+            if (profile == null)
             {
-                _logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name);
+                _logger.LogInformation("No matching device profile found. The default will need to be used. \n{@Profile}", deviceInfo);
             }
             else
             {
-                LogUnmatchedProfile(deviceInfo);
+                _logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name);
             }
 
             return profile;
         }
 
-        private void LogUnmatchedProfile(DeviceIdentification profile)
-        {
-            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());
-        }
-
         /// <summary>
         /// Attempts to match a device with a profile.
         /// Rules:
@@ -187,7 +168,8 @@ namespace Emby.Dlna
             }
         }
 
-        public DeviceProfile GetProfile(IHeaderDictionary headers)
+        /// <inheritdoc />
+        public DeviceProfile? GetProfile(IHeaderDictionary headers)
         {
             if (headers == null)
             {
@@ -195,15 +177,13 @@ namespace Emby.Dlna
             }
 
             var profile = GetProfiles().FirstOrDefault(i => i.Identification != null && IsMatch(headers, i.Identification));
-
-            if (profile != null)
+            if (profile == null)
             {
-                _logger.LogDebug("Found matching device profile: {0}", profile.Name);
+                _logger.LogDebug("No matching device profile found. {@Headers}", headers);
             }
             else
             {
-                var headerString = string.Join(", ", headers.Select(i => string.Format(CultureInfo.InvariantCulture, "{0}={1}", i.Key, i.Value)));
-                _logger.LogDebug("No matching device profile found. {0}", headerString);
+                _logger.LogDebug("Found matching device profile: {0}", profile.Name);
             }
 
             return profile;
@@ -253,19 +233,19 @@ namespace Emby.Dlna
                 return xmlFies
                     .Select(i => ParseProfileFile(i, type))
                     .Where(i => i != null)
-                    .ToList();
+                    .ToList()!; // We just filtered out all the nulls
             }
             catch (IOException)
             {
-                return new List<DeviceProfile>();
+                return Array.Empty<DeviceProfile>();
             }
         }
 
-        private DeviceProfile ParseProfileFile(string path, DeviceProfileType type)
+        private DeviceProfile? ParseProfileFile(string path, DeviceProfileType type)
         {
             lock (_profiles)
             {
-                if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile> profileTuple))
+                if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile>? profileTuple))
                 {
                     return profileTuple.Item2;
                 }
@@ -293,7 +273,8 @@ namespace Emby.Dlna
             }
         }
 
-        public DeviceProfile GetProfile(string id)
+        /// <inheritdoc />
+        public DeviceProfile? GetProfile(string id)
         {
             if (string.IsNullOrEmpty(id))
             {
@@ -322,6 +303,7 @@ namespace Emby.Dlna
             }
         }
 
+        /// <inheritdoc />
         public IEnumerable<DeviceProfileInfo> GetProfileInfos()
         {
             return GetProfileInfosInternal().Select(i => i.Info);
@@ -329,17 +311,14 @@ namespace Emby.Dlna
 
         private InternalProfileInfo GetInternalProfileInfo(FileSystemMetadata file, DeviceProfileType type)
         {
-            return new InternalProfileInfo
-            {
-                Path = file.FullName,
-
-                Info = new DeviceProfileInfo
+            return new InternalProfileInfo(
+                new DeviceProfileInfo
                 {
                     Id = file.FullName.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture),
                     Name = _fileSystem.GetFileNameWithoutExtension(file),
                     Type = type
-                }
-            };
+                },
+                file.FullName);
         }
 
         private async Task ExtractSystemProfilesAsync()
@@ -359,16 +338,20 @@ namespace Emby.Dlna
                     systemProfilesPath,
                     Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length));
 
-                using (var stream = _assembly.GetManifestResourceStream(name))
+                // The stream should exist as we just got its name from GetManifestResourceNames
+                using (var stream = _assembly.GetManifestResourceStream(name)!)
                 {
+                    var length = stream.Length;
                     var fileInfo = _fileSystem.GetFileInfo(path);
 
-                    if (!fileInfo.Exists || fileInfo.Length != stream.Length)
+                    if (!fileInfo.Exists || fileInfo.Length != length)
                     {
                         Directory.CreateDirectory(systemProfilesPath);
 
-                        // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 .
-                        using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
+                        var fileOptions = AsyncFile.WriteOptions;
+                        fileOptions.Mode = FileMode.Create;
+                        fileOptions.PreallocationSize = length;
+                        using (var fileStream = new FileStream(path, fileOptions))
                         {
                             await stream.CopyToAsync(fileStream).ConfigureAwait(false);
                         }
@@ -380,6 +363,7 @@ namespace Emby.Dlna
             Directory.CreateDirectory(UserProfilesPath);
         }
 
+        /// <inheritdoc />
         public void DeleteProfile(string id)
         {
             var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase));
@@ -397,6 +381,7 @@ namespace Emby.Dlna
             }
         }
 
+        /// <inheritdoc />
         public void CreateProfile(DeviceProfile profile)
         {
             profile = ReserializeProfile(profile);
@@ -412,7 +397,8 @@ namespace Emby.Dlna
             SaveProfile(profile, path, DeviceProfileType.User);
         }
 
-        public void UpdateProfile(DeviceProfile profile)
+        /// <inheritdoc />
+        public void UpdateProfile(string profileId, DeviceProfile profile)
         {
             profile = ReserializeProfile(profile);
 
@@ -426,7 +412,7 @@ namespace Emby.Dlna
                 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 path = Path.Combine(UserProfilesPath, newFilename);
@@ -470,9 +456,11 @@ namespace Emby.Dlna
 
             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)
         {
             var profile = GetDefaultProfile();
@@ -482,26 +470,37 @@ namespace Emby.Dlna
             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)
                 ? ImageFormat.Png
                 : ImageFormat.Jpg;
 
             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
         {
-            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; }
         }
     }
 

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

@@ -17,11 +17,9 @@
   </ItemGroup>
 
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
-    <Nullable>enable</Nullable>
   </PropertyGroup>
 
   <!-- Code Analyzers-->
@@ -31,10 +29,6 @@
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
   </ItemGroup>
 
-  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
-  </PropertyGroup>
-
   <ItemGroup>
     <EmbeddedResource Include="Images\logo120.jpg" />
     <EmbeddedResource Include="Images\logo120.png" />
@@ -78,7 +72,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
   </ItemGroup>
 
 </Project>

+ 3 - 3
Emby.Dlna/EventSubscriptionResponse.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System.Collections.Generic;
@@ -8,8 +6,10 @@ namespace Emby.Dlna
 {
     public class EventSubscriptionResponse
     {
-        public EventSubscriptionResponse()
+        public EventSubscriptionResponse(string content, string contentType)
         {
+            Content = content;
+            ContentType = contentType;
             Headers = new Dictionary<string, string>();
         }
 

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

@@ -11,6 +11,7 @@ using System.Net.Http;
 using System.Net.Mime;
 using System.Text;
 using System.Threading.Tasks;
+using Jellyfin.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using Microsoft.Extensions.Logging;
@@ -25,8 +26,6 @@ namespace Emby.Dlna.Eventing
         private readonly ILogger _logger;
         private readonly IHttpClientFactory _httpClientFactory;
 
-        private readonly CultureInfo _usCulture = new CultureInfo("en-US");
-
         public DlnaEventManager(ILogger logger, IHttpClientFactory httpClientFactory)
         {
             _httpClientFactory = httpClientFactory;
@@ -51,11 +50,7 @@ namespace Emby.Dlna.Eventing
                 return GetEventSubscriptionResponse(subscriptionId, requestedTimeoutString, timeoutSeconds);
             }
 
-            return new EventSubscriptionResponse
-            {
-                Content = string.Empty,
-                ContentType = "text/plain"
-            };
+            return new EventSubscriptionResponse(string.Empty, "text/plain");
         }
 
         public EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl)
@@ -86,9 +81,7 @@ namespace Emby.Dlna.Eventing
             if (!string.IsNullOrEmpty(header))
             {
                 // 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;
                 }
@@ -103,23 +96,15 @@ namespace Emby.Dlna.Eventing
 
             _subscriptions.TryRemove(subscriptionId, out _);
 
-            return new EventSubscriptionResponse
-            {
-                Content = string.Empty,
-                ContentType = "text/plain"
-            };
+            return new EventSubscriptionResponse(string.Empty, "text/plain");
         }
 
         private EventSubscriptionResponse GetEventSubscriptionResponse(string subscriptionId, string requestedTimeoutString, int timeoutSeconds)
         {
-            var response = new EventSubscriptionResponse
-            {
-                Content = string.Empty,
-                ContentType = "text/plain"
-            };
+            var response = new EventSubscriptionResponse(string.Empty, "text/plain");
 
             response.Headers["SID"] = subscriptionId;
-            response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(_usCulture)) : requestedTimeoutString;
+            response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(CultureInfo.InvariantCulture)) : requestedTimeoutString;
 
             return response;
         }
@@ -176,7 +161,7 @@ namespace Emby.Dlna.Eventing
             options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType);
             options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange");
             options.Headers.TryAddWithoutValidation("SID", subscription.Id);
-            options.Headers.TryAddWithoutValidation("SEQ", subscription.TriggerCount.ToString(_usCulture));
+            options.Headers.TryAddWithoutValidation("SEQ", subscription.TriggerCount.ToString(CultureInfo.InvariantCulture));
 
             try
             {

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

@@ -27,11 +27,9 @@ using MediaBrowser.Controller.TV;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Net;
-using MediaBrowser.Model.System;
 using Microsoft.Extensions.Logging;
 using Rssdp;
 using Rssdp.Infrastructure;
-using OperatingSystem = MediaBrowser.Common.System.OperatingSystem;
 
 namespace Emby.Dlna.Main
 {
@@ -54,7 +52,6 @@ namespace Emby.Dlna.Main
         private readonly ISocketFactory _socketFactory;
         private readonly INetworkManager _networkManager;
         private readonly object _syncLock = new object();
-        private readonly NetworkConfiguration _netConfig;
         private readonly bool _disabled;
 
         private PlayToManager _manager;
@@ -127,8 +124,8 @@ namespace Emby.Dlna.Main
                 config);
             Current = this;
 
-            _netConfig = config.GetConfiguration<NetworkConfiguration>("network");
-            _disabled = appHost.ListenWithHttps && _netConfig.RequireHttps;
+            var netConfig = config.GetConfiguration<NetworkConfiguration>(NetworkConfigurationStore.StoreKey);
+            _disabled = appHost.ListenWithHttps && netConfig.RequireHttps;
 
             if (_disabled && _config.GetDlnaConfiguration().EnableServer)
             {
@@ -204,8 +201,8 @@ namespace Emby.Dlna.Main
             {
                 if (_communicationsServer == null)
                 {
-                    var enableMultiSocketBinding = OperatingSystem.Id == OperatingSystemId.Windows ||
-                                                   OperatingSystem.Id == OperatingSystemId.Linux;
+                    var enableMultiSocketBinding = OperatingSystem.IsWindows() ||
+                                                   OperatingSystem.IsLinux();
 
                     _communicationsServer = new SsdpCommunicationsServer(_socketFactory, _networkManager, _logger, enableMultiSocketBinding)
                     {
@@ -221,11 +218,6 @@ namespace Emby.Dlna.Main
             }
         }
 
-        private void LogMessage(string msg)
-        {
-            _logger.LogDebug(msg);
-        }
-
         private void StartDeviceDiscovery(ISsdpCommunicationsServer communicationsServer)
         {
             try
@@ -268,9 +260,13 @@ namespace Emby.Dlna.Main
 
             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
                 };
 
@@ -315,15 +311,9 @@ namespace Emby.Dlna.Main
 
                 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);
-                if (!string.IsNullOrEmpty(_appHost.PublishedServerUrl))
-                {
-                    // DLNA will only work over http, so we must reset to http:// : {port}.
-                    uri.Scheme = "http";
-                    uri.Port = _netConfig.HttpServerPortNumber;
-                }
+                var uri = new UriBuilder(_appHost.GetApiUrlForLocalAccess(false) + descriptorUri);
 
                 var device = new SsdpRootDevice
                 {
@@ -409,7 +399,6 @@ namespace Emby.Dlna.Main
                         _imageProcessor,
                         _deviceDiscovery,
                         _httpClientFactory,
-                        _config,
                         _userDataManager,
                         _localization,
                         _mediaSourceManager,

+ 9 - 24
Emby.Dlna/PlayTo/Device.cs

@@ -20,8 +20,6 @@ namespace Emby.Dlna.PlayTo
 {
     public class Device : IDisposable
     {
-        private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
-
         private readonly IHttpClientFactory _httpClientFactory;
 
         private readonly ILogger _logger;
@@ -640,7 +638,7 @@ namespace Emby.Dlna.PlayTo
                 return;
             }
 
-            Volume = int.Parse(volumeValue, UsCulture);
+            Volume = int.Parse(volumeValue, CultureInfo.InvariantCulture);
 
             if (Volume > 0)
             {
@@ -842,7 +840,7 @@ namespace Emby.Dlna.PlayTo
             if (!string.IsNullOrWhiteSpace(duration)
                 && !string.Equals(duration, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase))
             {
-                Duration = TimeSpan.Parse(duration, UsCulture);
+                Duration = TimeSpan.Parse(duration, CultureInfo.InvariantCulture);
             }
             else
             {
@@ -854,7 +852,7 @@ namespace Emby.Dlna.PlayTo
 
             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();
@@ -1194,8 +1192,8 @@ namespace Emby.Dlna.PlayTo
             var depth = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("depth"));
             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
             {
@@ -1260,10 +1258,7 @@ namespace Emby.Dlna.PlayTo
                 return;
             }
 
-            PlaybackStart?.Invoke(this, new PlaybackStartEventArgs
-            {
-                MediaInfo = mediaInfo
-            });
+            PlaybackStart?.Invoke(this, new PlaybackStartEventArgs(mediaInfo));
         }
 
         private void OnPlaybackProgress(UBaseObject mediaInfo)
@@ -1273,27 +1268,17 @@ namespace Emby.Dlna.PlayTo
                 return;
             }
 
-            PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs
-            {
-                MediaInfo = mediaInfo
-            });
+            PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs(mediaInfo));
         }
 
         private void OnPlaybackStop(UBaseObject mediaInfo)
         {
-            PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs
-            {
-                MediaInfo = mediaInfo
-            });
+            PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs(mediaInfo));
         }
 
         private void OnMediaChanged(UBaseObject old, UBaseObject newMedia)
         {
-            MediaChanged?.Invoke(this, new MediaChangedEventArgs
-            {
-                OldMediaInfo = old,
-                NewMediaInfo = newMedia
-            });
+            MediaChanged?.Invoke(this, new MediaChangedEventArgs(old, newMedia));
         }
 
         /// <inheritdoc />

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

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

+ 3 - 5
Emby.Dlna/PlayTo/PlayToController.cs

@@ -30,8 +30,6 @@ namespace Emby.Dlna.PlayTo
 {
     public class PlayToController : ISessionController, IDisposable
     {
-        private static readonly CultureInfo _usCulture = CultureInfo.ReadOnly(new CultureInfo("en-US"));
-
         private readonly SessionInfo _session;
         private readonly ISessionManager _sessionManager;
         private readonly ILibraryManager _libraryManager;
@@ -716,7 +714,7 @@ namespace Emby.Dlna.PlayTo
                 case GeneralCommandType.SetAudioStreamIndex:
                     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);
                         }
@@ -728,7 +726,7 @@ namespace Emby.Dlna.PlayTo
                 case GeneralCommandType.SetSubtitleStreamIndex:
                     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);
                         }
@@ -740,7 +738,7 @@ namespace Emby.Dlna.PlayTo
                 case GeneralCommandType.SetVolume:
                     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);
                         }

+ 4 - 5
Emby.Dlna/PlayTo/PlayToManager.cs

@@ -11,7 +11,6 @@ using System.Threading.Tasks;
 using Jellyfin.Data.Events;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller;
-using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Library;
@@ -35,7 +34,6 @@ namespace Emby.Dlna.PlayTo
         private readonly IServerApplicationHost _appHost;
         private readonly IImageProcessor _imageProcessor;
         private readonly IHttpClientFactory _httpClientFactory;
-        private readonly IServerConfigurationManager _config;
         private readonly IUserDataManager _userDataManager;
         private readonly ILocalizationManager _localization;
 
@@ -47,7 +45,7 @@ namespace Emby.Dlna.PlayTo
         private SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1);
         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;
             _sessionManager = sessionManager;
@@ -58,7 +56,6 @@ namespace Emby.Dlna.PlayTo
             _imageProcessor = imageProcessor;
             _deviceDiscovery = deviceDiscovery;
             _httpClientFactory = httpClientFactory;
-            _config = config;
             _userDataManager = userDataManager;
             _localization = localization;
             _mediaSourceManager = mediaSourceManager;
@@ -173,7 +170,9 @@ namespace Emby.Dlna.PlayTo
                 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();
 

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

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -8,6 +6,11 @@ namespace Emby.Dlna.PlayTo
 {
     public class PlaybackProgressEventArgs : EventArgs
     {
+        public PlaybackProgressEventArgs(UBaseObject mediaInfo)
+        {
+            MediaInfo = mediaInfo;
+        }
+
         public UBaseObject MediaInfo { get; set; }
     }
 }

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

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -8,6 +6,11 @@ namespace Emby.Dlna.PlayTo
 {
     public class PlaybackStartEventArgs : EventArgs
     {
+        public PlaybackStartEventArgs(UBaseObject mediaInfo)
+        {
+            MediaInfo = mediaInfo;
+        }
+
         public UBaseObject MediaInfo { get; set; }
     }
 }

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

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -8,6 +6,11 @@ namespace Emby.Dlna.PlayTo
 {
     public class PlaybackStoppedEventArgs : EventArgs
     {
+        public PlaybackStoppedEventArgs(UBaseObject mediaInfo)
+        {
+            MediaInfo = mediaInfo;
+        }
+
         public UBaseObject MediaInfo { get; set; }
     }
 }

+ 9 - 7
Emby.Dlna/PlayTo/SsdpHttpClient.cs

@@ -20,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 FriendlyName = "Jellyfin";
 
-        private readonly CultureInfo _usCulture = new CultureInfo("en-US");
-
         private readonly IHttpClientFactory _httpClientFactory;
 
         public SsdpHttpClient(IHttpClientFactory httpClientFactory)
@@ -45,10 +43,12 @@ namespace Emby.Dlna.PlayTo
                     header,
                     cancellationToken)
                 .ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
+
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             return await XDocument.LoadAsync(
                 stream,
-                LoadOptions.PreserveWhitespace,
+                LoadOptions.None,
                 cancellationToken).ConfigureAwait(false);
         }
 
@@ -78,14 +78,15 @@ namespace Emby.Dlna.PlayTo
         {
             using var options = new HttpRequestMessage(new HttpMethod("SUBSCRIBE"), url);
             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("TIMEOUT", "Second-" + timeOut.ToString(_usCulture));
+            options.Headers.TryAddWithoutValidation("TIMEOUT", "Second-" + timeOut.ToString(CultureInfo.InvariantCulture));
 
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
                 .SendAsync(options, HttpCompletionOption.ResponseHeadersRead)
                 .ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
         }
 
         public async Task<XDocument> GetDataAsync(string url, CancellationToken cancellationToken)
@@ -94,12 +95,13 @@ namespace Emby.Dlna.PlayTo
             options.Headers.UserAgent.ParseAdd(USERAGENT);
             options.Headers.TryAddWithoutValidation("FriendlyName.DLNA.ORG", FriendlyName);
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             try
             {
                 return await XDocument.LoadAsync(
                     stream,
-                    LoadOptions.PreserveWhitespace,
+                    LoadOptions.None,
                     cancellationToken).ConfigureAwait(false);
             }
             catch

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

@@ -15,7 +15,6 @@ namespace Emby.Dlna.Server
     {
         private readonly DeviceProfile _profile;
 
-        private readonly CultureInfo _usCulture = new CultureInfo("en-US");
         private readonly string _serverUdn;
         private readonly string _serverAddress;
         private readonly string _serverName;
@@ -193,10 +192,10 @@ namespace Emby.Dlna.Server
                     .Append(SecurityElement.Escape(icon.MimeType ?? string.Empty))
                     .Append("</mimetype>");
                 builder.Append("<width>")
-                    .Append(SecurityElement.Escape(icon.Width.ToString(_usCulture)))
+                    .Append(SecurityElement.Escape(icon.Width.ToString(CultureInfo.InvariantCulture)))
                     .Append("</width>");
                 builder.Append("<height>")
-                    .Append(SecurityElement.Escape(icon.Height.ToString(_usCulture)))
+                    .Append(SecurityElement.Escape(icon.Height.ToString(CultureInfo.InvariantCulture)))
                     .Append("</height>");
                 builder.Append("<depth>")
                     .Append(SecurityElement.Escape(icon.Depth ?? string.Empty))
@@ -250,8 +249,7 @@ namespace Emby.Dlna.Server
 
             url = _serverAddress.TrimEnd('/') + "/dlna/" + _serverUdn + "/" + url.TrimStart('/');
 
-            // TODO: @bond remove null-coalescing operator when https://github.com/dotnet/runtime/pull/52442 is merged/released
-            return SecurityElement.Escape(url) ?? string.Empty;
+            return SecurityElement.Escape(url);
         }
 
         private IEnumerable<DeviceIcon> GetIcons()

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

@@ -6,9 +6,9 @@ using System.IO;
 using System.Text;
 using System.Threading.Tasks;
 using System.Xml;
+using Diacritics.Extensions;
 using Emby.Dlna.Didl;
 using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Extensions;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Dlna.Service
@@ -64,7 +64,7 @@ namespace Emby.Dlna.Service
                 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
             {
@@ -95,11 +95,7 @@ namespace Emby.Dlna.Service
 
             var xml = builder.ToString().Replace("xmlns:m=", "xmlns:u=", StringComparison.Ordinal);
 
-            var controlResponse = new ControlResponse
-            {
-                Xml = xml,
-                IsSuccessful = true
-            };
+            var controlResponse = new ControlResponse(xml, true);
 
             controlResponse.Headers.Add("EXT", string.Empty);
 

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

@@ -23,14 +23,14 @@ namespace Emby.Dlna.Service
             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();
             }
 
-            return new ControlResponse
-            {
-                Xml = builder.ToString(),
-                IsSuccessful = false
-            };
+            return new ControlResponse(builder.ToString(), false);
         }
     }
 }

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

@@ -6,11 +6,9 @@
   </PropertyGroup>
 
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
-    <Nullable>enable</Nullable>
   </PropertyGroup>
 
   <ItemGroup>
@@ -30,8 +28,4 @@
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
   </ItemGroup>
 
-  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
-  </PropertyGroup>
-
 </Project>

+ 2 - 2
Emby.Drawing/ImageProcessor.cs

@@ -26,7 +26,7 @@ namespace Emby.Drawing
     public sealed class ImageProcessor : IImageProcessor, IDisposable
     {
         // 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
             = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
@@ -102,7 +102,7 @@ namespace Emby.Drawing
         {
             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);
             }

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

@@ -1,7 +1,7 @@
 using System;
 using System.IO;
 using Emby.Naming.Common;
-using MediaBrowser.Common.Extensions;
+using Jellyfin.Extensions;
 
 namespace Emby.Naming.Audio
 {

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

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

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

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

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

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

+ 75 - 21
Emby.Naming/Common/NamingOptions.cs

@@ -1,4 +1,7 @@
+#pragma warning disable CA1819
+
 using System;
+using System.Collections.Generic;
 using System.Linq;
 using System.Text.RegularExpressions;
 using Emby.Naming.Video;
@@ -122,11 +125,11 @@ namespace Emby.Naming.Common
                     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[]
@@ -137,8 +140,11 @@ namespace Emby.Naming.Common
 
             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[]
@@ -250,6 +256,8 @@ namespace Emby.Naming.Common
                 },
                 // <!-- foo.ep01, foo.EP_01 -->
                 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)
                 {
                     DateTimeFormats = new[]
@@ -277,14 +285,14 @@ namespace Emby.Naming.Common
                     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
                 },
 
                 // 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]+).*$")
+                new EpisodeExpression(@".*[\\\/]?.*?(\[.*?\])+.*?(?<seriesname>[-\w\s]+?)[\s_]*-[\s_]*(?<epnumber>[0-9]+).*$")
                 {
                     IsNamed = true
                 },
@@ -305,6 +313,12 @@ namespace Emby.Naming.Common
 
                 // *** End Kodi Standard Naming
 
+                // "Episode 16", "Episode 16 - Title"
+                new EpisodeExpression(@"[Ee]pisode (?<epnumber>[0-9]+)(-(?<endingepnumber>[0-9]+))?[^\\\/]*$")
+                {
+                    IsNamed = true
+                },
+
                 new EpisodeExpression(@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]+)[xX](?<epnumber>[0-9]+)[^\\\/]*$")
                 {
                     IsNamed = true
@@ -362,12 +376,20 @@ namespace Emby.Naming.Common
                     IsOptimistic = 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]+)")
+                {
+                    IsNamed = true
+                },
+
+                // Series and season only expression
+                // "the show S01", "the show season 1"
+                new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)[\. _\-]+[sS](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)")
                 {
-                    IsOptimistic = true,
                     IsNamed = true
-                }
+                },
             };
 
             EpisodeWithoutSeasonExpressions = new[]
@@ -382,6 +404,12 @@ namespace Emby.Naming.Common
 
             VideoExtraRules = new[]
             {
+                new ExtraRule(
+                    ExtraType.Trailer,
+                    ExtraRuleType.DirectoryName,
+                    "trailers",
+                    MediaType.Video),
+
                 new ExtraRule(
                     ExtraType.Trailer,
                     ExtraRuleType.Filename,
@@ -448,6 +476,12 @@ namespace Emby.Naming.Common
                     "theme",
                     MediaType.Audio),
 
+                new ExtraRule(
+                    ExtraType.ThemeSong,
+                    ExtraRuleType.DirectoryName,
+                    "theme-music",
+                    MediaType.Audio),
+
                 new ExtraRule(
                     ExtraType.Scene,
                     ExtraRuleType.Suffix,
@@ -478,6 +512,12 @@ namespace Emby.Naming.Common
                     "-deleted",
                     MediaType.Video),
 
+                new ExtraRule(
+                    ExtraType.DeletedScene,
+                    ExtraRuleType.Suffix,
+                    "-deletedscene",
+                    MediaType.Video),
+
                 new ExtraRule(
                     ExtraType.Clip,
                     ExtraRuleType.Suffix,
@@ -536,7 +576,7 @@ namespace Emby.Naming.Common
                     ExtraType.Unknown,
                     ExtraRuleType.DirectoryName,
                     "extras",
-                    MediaType.Video),
+                    MediaType.Video)
             };
 
             Format3DRules = new[]
@@ -648,9 +688,29 @@ namespace Emby.Naming.Common
                 .Distinct(StringComparer.OrdinalIgnoreCase)
                 .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();
         }
 
+        /// <summary>
+        /// Gets or sets the folder name to extra types mapping.
+        /// </summary>
+        public Dictionary<string, ExtraType> AllExtrasTypesFolderNames { get; set; }
+
         /// <summary>
         /// Gets or sets list of audio file extensions.
         /// </summary>
@@ -732,9 +792,9 @@ namespace Emby.Naming.Common
         public Format3DRule[] Format3DRules { get; set; }
 
         /// <summary>
-        /// Gets or sets list of raw video file-stacking expressions strings.
+        /// Gets the file stacking rules.
         /// </summary>
-        public string[] VideoFileStackingExpressions { get; set; }
+        public FileStackRule[] VideoFileStackingRules { get; }
 
         /// <summary>
         /// Gets or sets list of raw clean DateTimes regular expressions strings.
@@ -756,11 +816,6 @@ namespace Emby.Naming.Common
         /// </summary>
         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>
         /// Gets list of clean datetime regular expressions.
         /// </summary>
@@ -786,7 +841,6 @@ namespace Emby.Naming.Common
         /// </summary>
         public void Compile()
         {
-            VideoFileStackingRegexes = VideoFileStackingExpressions.Select(Compile).ToArray();
             CleanDateTimeRegexes = CleanDateTimes.Select(Compile).ToArray();
             CleanStringRegexes = CleanStrings.Select(Compile).ToArray();
             EpisodeWithoutSeasonRegexes = EpisodeWithoutSeasonExpressions.Select(Compile).ToArray();

+ 3 - 9
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 -->
   <PropertyGroup>
@@ -6,15 +6,13 @@
   </PropertyGroup>
 
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
     <PublishRepositoryUrl>true</PublishRepositoryUrl>
     <EmbedUntrackedSources>true</EmbedUntrackedSources>
     <IncludeSymbols>true</IncludeSymbols>
     <SymbolPackageFormat>snupkg</SymbolPackageFormat>
-    <Nullable>enable</Nullable>
   </PropertyGroup>
 
   <PropertyGroup Condition=" '$(Stability)'=='Unstable'">
@@ -40,7 +38,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
+    <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
   </ItemGroup>
 
   <!-- Code Analyzers-->
@@ -50,8 +48,4 @@
     <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
   </ItemGroup>
 
-  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
-  </PropertyGroup>
-
 </Project>

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

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

+ 2 - 2
Emby.Naming/TV/EpisodeResolver.cs

@@ -1,8 +1,8 @@
 using System;
 using System.IO;
-using System.Linq;
 using Emby.Naming.Common;
 using Emby.Naming.Video;
+using Jellyfin.Extensions;
 
 namespace Emby.Naming.TV
 {
@@ -48,7 +48,7 @@ namespace Emby.Naming.TV
             {
                 var extension = Path.GetExtension(path);
                 // Check supported extensions
-                if (!_options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
+                if (!_options.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
                 {
                     // It's not supported. Check stub extensions
                     if (!StubResolver.TryResolveFile(path, _options, out stubType))

+ 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
+            };
+        }
+    }
+}

+ 13 - 13
Emby.Naming/Video/CleanStringParser.cs

@@ -1,4 +1,3 @@
-using System;
 using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
 using System.Text.RegularExpressions;
@@ -17,38 +16,39 @@ namespace Emby.Naming.Video
         /// <param name="expressions">List of regex to parse name and year from.</param>
         /// <param name="newName">Parsing result string.</param>
         /// <returns>True if parsing was successful.</returns>
-        public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList<Regex> expressions, out ReadOnlySpan<char> newName)
+        public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList<Regex> expressions, out string newName)
         {
             if (string.IsNullOrEmpty(name))
             {
-                newName = ReadOnlySpan<char>.Empty;
+                newName = string.Empty;
                 return false;
             }
 
-            var len = expressions.Count;
-            for (int i = 0; i < len; i++)
+            // 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))
                 {
-                    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);
-            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;
             }
 
-            newName = ReadOnlySpan<char>.Empty;
+            newName = string.Empty;
             return false;
         }
     }

+ 76 - 30
Emby.Naming/Video/ExtraResolver.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.IO;
 using System.Linq;
 using System.Text.RegularExpressions;
@@ -10,44 +11,27 @@ namespace Emby.Naming.Video
     /// <summary>
     /// Resolve if file is extra for video.
     /// </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>
         /// Attempts to resolve if file is extra.
         /// </summary>
         /// <param name="path">Path to file.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Returns <see cref="ExtraResult"/> object.</returns>
-        public ExtraResult GetExtraInfo(string path)
+        public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions)
         {
             var result = new ExtraResult();
 
-            for (var i = 0; i < _options.VideoExtraRules.Length; i++)
+            for (var i = 0; i < namingOptions.VideoExtraRules.Length; i++)
             {
-                var rule = _options.VideoExtraRules[i];
-                if (rule.MediaType == MediaType.Audio)
+                var rule = namingOptions.VideoExtraRules[i];
+                if ((rule.MediaType == MediaType.Audio && !AudioFileParser.IsAudioFile(path, namingOptions))
+                    || (rule.MediaType == MediaType.Video && !VideoResolver.IsVideoFile(path, namingOptions)))
                 {
-                    if (!AudioFileParser.IsAudioFile(path, _options))
-                    {
-                        continue;
-                    }
-                }
-                else if (rule.MediaType == MediaType.Video)
-                {
-                    if (!VideoResolver.IsVideoFile(path, _options))
-                    {
-                        continue;
-                    }
+                    continue;
                 }
 
                 var pathSpan = path.AsSpan();
@@ -63,9 +47,10 @@ namespace Emby.Naming.Video
                 }
                 else if (rule.RuleType == ExtraRuleType.Suffix)
                 {
-                    var filename = Path.GetFileNameWithoutExtension(pathSpan);
+                    // Trim the digits from the end of the filename so we can recognize things like -trailer2
+                    var filename = Path.GetFileNameWithoutExtension(pathSpan).TrimEnd(_digits);
 
-                    if (filename.Contains(rule.Token, StringComparison.OrdinalIgnoreCase))
+                    if (filename.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase))
                     {
                         result.ExtraType = rule.ExtraType;
                         result.Rule = rule;
@@ -75,9 +60,9 @@ namespace Emby.Naming.Video
                 {
                     var filename = Path.GetFileName(path);
 
-                    var regex = new Regex(rule.Token, RegexOptions.IgnoreCase);
+                    var isMatch = Regex.IsMatch(filename, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled);
 
-                    if (regex.IsMatch(filename))
+                    if (isMatch)
                     {
                         result.ExtraType = rule.ExtraType;
                         result.Rule = rule;
@@ -101,5 +86,66 @@ namespace Emby.Naming.Video
 
             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 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);
+
+                // first check filenames
+                bool isValid = StartsWith(trimmedCurrentFileName, trimmedFileNameWithoutExtension)
+                               || (StartsWith(trimmedCurrentFileName, trimmedVideoInfoName) && currentFile.Year == videoInfo.Year);
+
+                // then by directory
+                if (!isValid)
+                {
+                    // 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);
+                }
+
+                if (isValid)
+                {
+                    result.Add(currentFile);
+                }
+            }
+
+            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.Collections.Generic;
-using System.Linq;
+using Jellyfin.Extensions;
 
 namespace Emby.Naming.Video
 {
@@ -12,25 +12,30 @@ namespace Emby.Naming.Video
         /// <summary>
         /// Initializes a new instance of the <see cref="FileStack"/> class.
         /// </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>
-        /// Gets or sets name of file stack.
+        /// Gets the name of file stack.
         /// </summary>
-        public string Name { get; set; } = string.Empty;
+        public string Name { get; }
 
         /// <summary>
-        /// Gets or sets list of paths in stack.
+        /// Gets the list of paths in stack.
         /// </summary>
-        public List<string> Files { get; set; }
+        public IReadOnlyList<string> Files { get; }
 
         /// <summary>
-        /// Gets or sets a value indicating whether stack is directory stack.
+        /// Gets a value indicating whether stack is directory stack.
         /// </summary>
-        public bool IsDirectoryStack { get; set; }
+        public bool IsDirectoryStack { get; }
 
         /// <summary>
         /// 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>
         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;
+    }
+}

+ 68 - 151
Emby.Naming/Video/StackResolver.cs

@@ -2,7 +2,6 @@ using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
-using System.Text.RegularExpressions;
 using Emby.Naming.AudioBook;
 using Emby.Naming.Common;
 using MediaBrowser.Model.IO;
@@ -12,37 +11,28 @@ namespace Emby.Naming.Video
     /// <summary>
     /// Resolve <see cref="FileStack"/> from list of paths.
     /// </summary>
-    public class StackResolver
+    public static class StackResolver
     {
-        private readonly NamingOptions _options;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="StackResolver"/> class.
-        /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileStackingRegexes and passes options to <see cref="VideoResolver"/>.</param>
-        public StackResolver(NamingOptions options)
-        {
-            _options = options;
-        }
-
         /// <summary>
         /// Resolves only directories from paths.
         /// </summary>
         /// <param name="files">List of paths.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns>
-        public IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files)
+        public static IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files, NamingOptions namingOptions)
         {
-            return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true }));
+            return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true }), namingOptions);
         }
 
         /// <summary>
         /// Resolves only files from paths.
         /// </summary>
         /// <param name="files">List of paths.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Enumerable <see cref="FileStack"/> of files.</returns>
-        public IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files)
+        public static IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files, NamingOptions namingOptions)
         {
-            return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false }));
+            return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false }), namingOptions);
         }
 
         /// <summary>
@@ -50,7 +40,7 @@ namespace Emby.Naming.Video
         /// </summary>
         /// <param name="files">List of paths.</param>
         /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns>
-        public IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files)
+        public static IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files)
         {
             var groupedDirectoryFiles = files.GroupBy(file => Path.GetDirectoryName(file.Path));
 
@@ -60,19 +50,13 @@ namespace Emby.Naming.Video
                 {
                     foreach (var file in directory)
                     {
-                        var stack = new FileStack { Name = Path.GetFileNameWithoutExtension(file.Path), IsDirectoryStack = false };
-                        stack.Files.Add(file.Path);
+                        var stack = new FileStack(Path.GetFileNameWithoutExtension(file.Path), false, new[] { file.Path });
                         yield return stack;
                     }
                 }
                 else
                 {
-                    var stack = new FileStack { Name = Path.GetFileName(directory.Key), IsDirectoryStack = false };
-                    foreach (var file in directory)
-                    {
-                        stack.Files.Add(file.Path);
-                    }
-
+                    var stack = new FileStack(Path.GetFileName(directory.Key), false, directory.Select(f => f.Path).ToArray());
                     yield return stack;
                 }
             }
@@ -82,158 +66,91 @@ namespace Emby.Naming.Video
         /// Resolves videos from paths.
         /// </summary>
         /// <param name="files">List of paths.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Enumerable <see cref="FileStack"/> of videos.</returns>
-        public IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files)
+        public static IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions)
         {
-            var list = files
-                .Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, _options) || VideoResolver.IsStubFile(i.FullName, _options))
-                .OrderBy(i => i.FullName)
-                .ToList();
-
-            var expressions = _options.VideoFileStackingRegexes;
+            var potentialFiles = files
+                .Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, namingOptions) || VideoResolver.IsStubFile(i.FullName, namingOptions))
+                .OrderBy(i => i.FullName);
 
-            for (var i = 0; i < list.Count; i++)
+            var potentialStacks = new Dictionary<string, StackMetadata>();
+            foreach (var file in potentialFiles)
             {
-                var offset = 0;
-
-                var file1 = list[i];
+                var name = file.Name;
+                if (string.IsNullOrEmpty(name))
+                {
+                    name = Path.GetFileName(file.FullName);
+                }
 
-                var expressionIndex = 0;
-                while (expressionIndex < expressions.Length)
+                for (var i = 0; i < namingOptions.VideoFileStackingRules.Length; i++)
                 {
-                    var exp = expressions[expressionIndex];
-                    var stack = new FileStack();
+                    var rule = namingOptions.VideoFileStackingRules[i];
+                    if (!rule.Match(name, out var stackParsingResult))
+                    {
+                        continue;
+                    }
 
-                    // (Title)(Volume)(Ignore)(Extension)
-                    var match1 = FindMatch(file1, exp, offset);
+                    var stackName = stackParsingResult.Value.StackName;
+                    var partNumber = stackParsingResult.Value.PartNumber;
+                    var partType = stackParsingResult.Value.PartType;
 
-                    if (match1.Success)
+                    if (!potentialStacks.TryGetValue(stackName, out var stackResult))
                     {
-                        var title1 = match1.Groups["title"].Value;
-                        var volume1 = match1.Groups["volume"].Value;
-                        var ignore1 = match1.Groups["ignore"].Value;
-                        var extension1 = match1.Groups["extension"].Value;
+                        stackResult = new StackMetadata(file.IsDirectory, rule.IsNumerical, partType);
+                        potentialStacks[stackName] = stackResult;
+                    }
 
-                        var j = i + 1;
-                        while (j < list.Count)
+                    if (stackResult.Parts.Count > 0)
+                    {
+                        if (stackResult.IsDirectory != file.IsDirectory
+                            || !string.Equals(partType, stackResult.PartType, StringComparison.OrdinalIgnoreCase)
+                            || stackResult.ContainsPart(partNumber))
                         {
-                            var file2 = list[j];
-
-                            if (file1.IsDirectory != file2.IsDirectory)
-                            {
-                                j++;
-                                continue;
-                            }
-
-                            // (Title)(Volume)(Ignore)(Extension)
-                            var match2 = FindMatch(file2, exp, offset);
-
-                            if (match2.Success)
-                            {
-                                var title2 = match2.Groups[1].Value;
-                                var volume2 = match2.Groups[2].Value;
-                                var ignore2 = match2.Groups[3].Value;
-                                var extension2 = match2.Groups[4].Value;
-
-                                if (string.Equals(title1, title2, StringComparison.OrdinalIgnoreCase))
-                                {
-                                    if (!string.Equals(volume1, volume2, StringComparison.OrdinalIgnoreCase))
-                                    {
-                                        if (string.Equals(ignore1, ignore2, StringComparison.OrdinalIgnoreCase)
-                                            && string.Equals(extension1, extension2, StringComparison.OrdinalIgnoreCase))
-                                        {
-                                            if (stack.Files.Count == 0)
-                                            {
-                                                stack.Name = title1 + ignore1;
-                                                stack.IsDirectoryStack = file1.IsDirectory;
-                                                stack.Files.Add(file1.FullName);
-                                            }
-
-                                            stack.Files.Add(file2.FullName);
-                                        }
-                                        else
-                                        {
-                                            // Sequel
-                                            offset = 0;
-                                            expressionIndex++;
-                                            break;
-                                        }
-                                    }
-                                    else if (!string.Equals(ignore1, ignore2, StringComparison.OrdinalIgnoreCase))
-                                    {
-                                        // False positive, try again with offset
-                                        offset = match1.Groups[3].Index;
-                                        break;
-                                    }
-                                    else
-                                    {
-                                        // Extension mismatch
-                                        offset = 0;
-                                        expressionIndex++;
-                                        break;
-                                    }
-                                }
-                                else
-                                {
-                                    // Title mismatch
-                                    offset = 0;
-                                    expressionIndex++;
-                                    break;
-                                }
-                            }
-                            else
-                            {
-                                // No match 2, next expression
-                                offset = 0;
-                                expressionIndex++;
-                                break;
-                            }
-
-                            j++;
+                            continue;
                         }
 
-                        if (j == list.Count)
+                        if (rule.IsNumerical != stackResult.IsNumerical)
                         {
-                            expressionIndex = expressions.Length;
+                            break;
                         }
                     }
-                    else
-                    {
-                        // No match 1
-                        offset = 0;
-                        expressionIndex++;
-                    }
 
-                    if (stack.Files.Count > 1)
-                    {
-                        yield return stack;
-                        i += stack.Files.Count - 1;
-                        break;
-                    }
+                    stackResult.Parts.Add(partNumber, file);
+                    break;
                 }
             }
-        }
 
-        private static string GetRegexInput(FileSystemMetadata file)
-        {
-            // For directories, dummy up an extension otherwise the expressions will fail
-            var input = !file.IsDirectory
-                ? file.FullName
-                : file.FullName + ".mkv";
+            foreach (var (fileName, stack) in potentialStacks)
+            {
+                if (stack.Parts.Count < 2)
+                {
+                    continue;
+                }
 
-            return Path.GetFileName(input);
+                yield return new FileStack(fileName, stack.IsDirectory, stack.Parts.Select(kv => kv.Value.FullName).ToArray());
+            }
         }
 
-        private static Match FindMatch(FileSystemMetadata input, Regex regex, int offset)
+        private class StackMetadata
         {
-            var regexInput = GetRegexInput(input);
-
-            if (offset < 0 || offset >= regexInput.Length)
+            public StackMetadata(bool isDirectory, bool isNumerical, string partType)
             {
-                return Match.Empty;
+                Parts = new Dictionary<string, FileSystemMetadata>(StringComparer.OrdinalIgnoreCase);
+                IsDirectory = isDirectory;
+                IsNumerical = isNumerical;
+                PartType = partType;
             }
 
-            return regex.Match(regexInput, offset);
+            public Dictionary<string, FileSystemMetadata> Parts { get; }
+
+            public bool IsDirectory { get; }
+
+            public bool IsNumerical { get; }
+
+            public string PartType { get; }
+
+            public bool ContainsPart(string partNumber) => Parts.ContainsKey(partNumber);
         }
     }
 }

+ 2 - 2
Emby.Naming/Video/StubResolver.cs

@@ -1,7 +1,7 @@
 using System;
 using System.IO;
-using System.Linq;
 using Emby.Naming.Common;
+using Jellyfin.Extensions;
 
 namespace Emby.Naming.Video
 {
@@ -28,7 +28,7 @@ namespace Emby.Naming.Video
 
             var extension = Path.GetExtension(path);
 
-            if (!options.StubFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
+            if (!options.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
             {
                 return false;
             }

+ 6 - 7
Emby.Naming/Video/VideoInfo.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using MediaBrowser.Model.Entities;
 
 namespace Emby.Naming.Video
 {
@@ -17,7 +18,6 @@ namespace Emby.Naming.Video
             Name = name;
 
             Files = Array.Empty<VideoFileInfo>();
-            Extras = Array.Empty<VideoFileInfo>();
             AlternateVersions = Array.Empty<VideoFileInfo>();
         }
 
@@ -39,16 +39,15 @@ namespace Emby.Naming.Video
         /// <value>The files.</value>
         public IReadOnlyList<VideoFileInfo> Files { get; set; }
 
-        /// <summary>
-        /// Gets or sets the extras.
-        /// </summary>
-        /// <value>The extras.</value>
-        public IReadOnlyList<VideoFileInfo> Extras { get; set; }
-
         /// <summary>
         /// Gets or sets the alternate versions.
         /// </summary>
         /// <value>The alternate versions.</value>
         public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; }
+
+        /// <summary>
+        /// Gets or sets the extra type.
+        /// </summary>
+        public ExtraType? ExtraType { get; set; }
     }
 }

+ 30 - 144
Emby.Naming/Video/VideoListResolver.cs

@@ -4,7 +4,6 @@ using System.IO;
 using System.Linq;
 using System.Text.RegularExpressions;
 using Emby.Naming.Common;
-using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 
 namespace Emby.Naming.Video
@@ -17,29 +16,38 @@ namespace Emby.Naming.Video
         /// <summary>
         /// Resolves alternative versions and extras from list of video files.
         /// </summary>
-        /// <param name="files">List of related video files.</param>
+        /// <param name="videoInfos">List of related video files.</param>
         /// <param name="namingOptions">The naming options.</param>
         /// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
+        /// <param name="parseName">Whether to parse the name or use the filename.</param>
         /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
-        public static IEnumerable<VideoInfo> Resolve(List<FileSystemMetadata> files, NamingOptions namingOptions, bool supportMultiVersion = true)
+        public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true)
         {
-            var videoInfos = files
-                .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions))
-                .OfType<VideoFileInfo>()
-                .ToList();
-
             // Filter out all extras, otherwise they could cause stacks to not be resolved
             // See the unit test TestStackedWithTrailer
             var nonExtras = videoInfos
                 .Where(i => i.ExtraType == null)
                 .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
 
-            var stackResult = new StackResolver(namingOptions)
-                .Resolve(nonExtras).ToList();
+            var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList();
 
-            var remainingFiles = videoInfos
-                .Where(i => !stackResult.Any(s => i.Path != null && s.ContainsFile(i.Path, i.IsDirectory)))
-                .ToList();
+            var remainingFiles = new List<VideoFileInfo>();
+            var standaloneMedia = new List<VideoFileInfo>();
+
+            for (var i = 0; i < videoInfos.Count; i++)
+            {
+                var current = videoInfos[i];
+                if (stackResult.Any(s => s.ContainsFile(current.Path, current.IsDirectory)))
+                {
+                    continue;
+                }
+
+                remainingFiles.Add(current);
+                if (current.ExtraType == null)
+                {
+                    standaloneMedia.Add(current);
+                }
+            }
 
             var list = new List<VideoInfo>();
 
@@ -47,27 +55,15 @@ namespace Emby.Naming.Video
             {
                 var info = new VideoInfo(stack.Name)
                 {
-                    Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions))
+                    Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName))
                         .OfType<VideoFileInfo>()
                         .ToList()
                 };
 
                 info.Year = info.Files[0].Year;
-
-                var extras = ExtractExtras(remainingFiles, stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0].AsSpan()), namingOptions.VideoFlagDelimiters);
-
-                if (extras.Count > 0)
-                {
-                    info.Extras = extras;
-                }
-
                 list.Add(info);
             }
 
-            var standaloneMedia = remainingFiles
-                .Where(i => i.ExtraType == null)
-                .ToList();
-
             foreach (var media in standaloneMedia)
             {
                 var info = new VideoInfo(media.Name) { Files = new[] { media } };
@@ -75,10 +71,6 @@ namespace Emby.Naming.Video
                 info.Year = info.Files[0].Year;
 
                 remainingFiles.Remove(media);
-                var extras = ExtractExtras(remainingFiles, media.FileNameWithoutExtension, namingOptions.VideoFlagDelimiters);
-
-                info.Extras = extras;
-
                 list.Add(info);
             }
 
@@ -87,58 +79,12 @@ namespace Emby.Naming.Video
                 list = GetVideosGroupedByVersion(list, namingOptions);
             }
 
-            // If there's only one resolved video, use the folder name as well to find extras
-            if (list.Count == 1)
-            {
-                var info = list[0];
-                var videoPath = list[0].Files[0].Path;
-                var parentPath = Path.GetDirectoryName(videoPath.AsSpan());
-
-                if (!parentPath.IsEmpty)
-                {
-                    var folderName = Path.GetFileName(parentPath);
-                    if (!folderName.IsEmpty)
-                    {
-                        var extras = ExtractExtras(remainingFiles, folderName, namingOptions.VideoFlagDelimiters);
-                        extras.AddRange(info.Extras);
-                        info.Extras = extras;
-                    }
-                }
-
-                // Add the extras that are just based on file name as well
-                var extrasByFileName = remainingFiles
-                    .Where(i => i.ExtraRule != null && i.ExtraRule.RuleType == ExtraRuleType.Filename)
-                    .ToList();
-
-                remainingFiles = remainingFiles
-                    .Except(extrasByFileName)
-                    .ToList();
-
-                extrasByFileName.AddRange(info.Extras);
-                info.Extras = extrasByFileName;
-            }
-
-            // If there's only one video, accept all trailers
-            // Be lenient because people use all kinds of mishmash conventions with trailers.
-            if (list.Count == 1)
-            {
-                var trailers = remainingFiles
-                    .Where(i => i.ExtraType == ExtraType.Trailer)
-                    .ToList();
-
-                trailers.AddRange(list[0].Extras);
-                list[0].Extras = trailers;
-
-                remainingFiles = remainingFiles
-                    .Except(trailers)
-                    .ToList();
-            }
-
             // Whatever files are left, just add them
             list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name)
             {
                 Files = new[] { i },
-                Year = i.Year
+                Year = i.Year,
+                ExtraType = i.ExtraType
             }));
 
             return list;
@@ -162,6 +108,11 @@ namespace Emby.Naming.Video
             for (var i = 0; i < videos.Count; i++)
             {
                 var video = videos[i];
+                if (video.ExtraType != null)
+                {
+                    continue;
+                }
+
                 if (!IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions))
                 {
                     return videos;
@@ -178,17 +129,14 @@ namespace Emby.Naming.Video
 
             var alternateVersionsLen = videos.Count - 1;
             var alternateVersions = new VideoFileInfo[alternateVersionsLen];
-            var extras = new List<VideoFileInfo>(list[0].Extras);
             for (int i = 0; i < alternateVersionsLen; i++)
             {
                 var video = videos[i + 1];
                 alternateVersions[i] = video.Files[0];
-                extras.AddRange(video.Extras);
             }
 
             list[0].AlternateVersions = alternateVersions;
             list[0].Name = folderName.ToString();
-            list[0].Extras = extras;
 
             return list;
         }
@@ -230,7 +178,7 @@ namespace Emby.Naming.Video
             var tmpTestFilename = testFilename.ToString();
             if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName))
             {
-                tmpTestFilename = cleanName.Trim().ToString();
+                tmpTestFilename = cleanName.Trim();
             }
 
             // The CleanStringParser should have removed common keywords etc.
@@ -238,67 +186,5 @@ namespace Emby.Naming.Video
                    || testFilename[0] == '-'
                    || Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled);
         }
-
-        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, ReadOnlySpan<char> trimmedBaseName)
-        {
-            if (baseName.IsEmpty)
-            {
-                return false;
-            }
-
-            return fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
-                   || (!trimmedBaseName.IsEmpty && fileName.StartsWith(trimmedBaseName, StringComparison.OrdinalIgnoreCase));
-        }
-
-        /// <summary>
-        /// Finds similar filenames to that of [baseName] and removes any matches from [remainingFiles].
-        /// </summary>
-        /// <param name="remainingFiles">The list of remaining filenames.</param>
-        /// <param name="baseName">The base name to use for the comparison.</param>
-        /// <param name="videoFlagDelimiters">The video flag delimiters.</param>
-        /// <returns>A list of video extras for [baseName].</returns>
-        private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> baseName, ReadOnlySpan<char> videoFlagDelimiters)
-        {
-            return ExtractExtras(remainingFiles, baseName, ReadOnlySpan<char>.Empty, videoFlagDelimiters);
-        }
-
-        /// <summary>
-        /// Finds similar filenames to that of [firstBaseName] and [secondBaseName] and removes any matches from [remainingFiles].
-        /// </summary>
-        /// <param name="remainingFiles">The list of remaining filenames.</param>
-        /// <param name="firstBaseName">The first base name to use for the comparison.</param>
-        /// <param name="secondBaseName">The second base name to use for the comparison.</param>
-        /// <param name="videoFlagDelimiters">The video flag delimiters.</param>
-        /// <returns>A list of video extras for [firstBaseName] and [secondBaseName].</returns>
-        private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> firstBaseName, ReadOnlySpan<char> secondBaseName, ReadOnlySpan<char> videoFlagDelimiters)
-        {
-            var trimmedFirstBaseName = TrimFilenameDelimiters(firstBaseName, videoFlagDelimiters);
-            var trimmedSecondBaseName = TrimFilenameDelimiters(secondBaseName, videoFlagDelimiters);
-
-            var result = new List<VideoFileInfo>();
-            for (var pos = remainingFiles.Count - 1; pos >= 0; pos--)
-            {
-                var file = remainingFiles[pos];
-                if (file.ExtraType == null)
-                {
-                    continue;
-                }
-
-                var filename = file.FileNameWithoutExtension;
-                if (StartsWith(filename, firstBaseName, trimmedFirstBaseName)
-                    || StartsWith(filename, secondBaseName, trimmedSecondBaseName))
-                {
-                    result.Add(file);
-                    remainingFiles.RemoveAt(pos);
-                }
-            }
-
-            return result;
-        }
     }
 }

+ 8 - 7
Emby.Naming/Video/VideoResolver.cs

@@ -2,7 +2,7 @@ using System;
 using System.Diagnostics.CodeAnalysis;
 using System.IO;
 using Emby.Naming.Common;
-using MediaBrowser.Common.Extensions;
+using Jellyfin.Extensions;
 
 namespace Emby.Naming.Video
 {
@@ -16,10 +16,11 @@ namespace Emby.Naming.Video
         /// </summary>
         /// <param name="path">The path.</param>
         /// <param name="namingOptions">The naming options.</param>
+        /// <param name="parseName">Whether to parse the name or use the filename.</param>
         /// <returns>VideoFileInfo.</returns>
-        public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions)
+        public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions, bool parseName = true)
         {
-            return Resolve(path, true, namingOptions);
+            return Resolve(path, true, namingOptions, parseName);
         }
 
         /// <summary>
@@ -74,7 +75,7 @@ namespace Emby.Naming.Video
 
             var format3DResult = Format3DParser.Parse(path, namingOptions);
 
-            var extraResult = new ExtraResolver(namingOptions).GetExtraInfo(path);
+            var extraResult = ExtraResolver.GetExtraInfo(path, namingOptions);
 
             var name = Path.GetFileNameWithoutExtension(path);
 
@@ -87,9 +88,9 @@ namespace Emby.Naming.Video
                 year = cleanDateTimeResult.Year;
 
                 if (extraResult.ExtraType == null
-                    && TryCleanString(name, namingOptions, out ReadOnlySpan<char> newName))
+                    && TryCleanString(name, namingOptions, out var newName))
                 {
-                    name = newName.ToString();
+                    name = newName;
                 }
             }
 
@@ -138,7 +139,7 @@ namespace Emby.Naming.Video
         /// <param name="namingOptions">The naming options.</param>
         /// <param name="newName">Clean name.</param>
         /// <returns>True if cleaning of name was successful.</returns>
-        public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out ReadOnlySpan<char> newName)
+        public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out string newName)
         {
             return CleanStringParser.TryClean(name, namingOptions.CleanStringRegexes, out newName);
         }

+ 1 - 5
Emby.Notifications/Emby.Notifications.csproj

@@ -6,13 +6,9 @@
   </PropertyGroup>
 
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
-    <Nullable>enable</Nullable>
-    <AnalysisMode>AllEnabledByDefault</AnalysisMode>
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
   </PropertyGroup>
 
   <ItemGroup>

+ 2 - 22
Emby.Notifications/NotificationEntryPoint.cs

@@ -5,6 +5,7 @@ using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Data.Events;
+using Jellyfin.Extensions;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Entities;
@@ -77,7 +78,6 @@ namespace Emby.Notifications
         {
             _libraryManager.ItemAdded += OnLibraryManagerItemAdded;
             _appHost.HasPendingRestartChanged += OnAppHostHasPendingRestartChanged;
-            _appHost.HasUpdateAvailableChanged += OnAppHostHasUpdateAvailableChanged;
             _activityManager.EntryCreated += OnActivityManagerEntryCreated;
 
             return Task.CompletedTask;
@@ -105,7 +105,7 @@ namespace Emby.Notifications
 
             var type = entry.Type;
 
-            if (string.IsNullOrEmpty(type) || !_coreNotificationTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
+            if (string.IsNullOrEmpty(type) || !_coreNotificationTypes.Contains(type, StringComparison.OrdinalIgnoreCase))
             {
                 return;
             }
@@ -132,25 +132,6 @@ namespace Emby.Notifications
             return _config.GetConfiguration<NotificationOptions>("notifications");
         }
 
-        private async void OnAppHostHasUpdateAvailableChanged(object? sender, EventArgs e)
-        {
-            if (!_appHost.HasUpdateAvailable)
-            {
-                return;
-            }
-
-            var type = NotificationType.ApplicationUpdateAvailable.ToString();
-
-            var notification = new NotificationRequest
-            {
-                Description = "Please see jellyfin.org for details.",
-                NotificationType = type,
-                Name = _localization.GetLocalizedString("NewVersionIsAvailable")
-            };
-
-            await SendNotification(notification, null).ConfigureAwait(false);
-        }
-
         private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e)
         {
             if (!FilterItem(e.Item))
@@ -325,7 +306,6 @@ namespace Emby.Notifications
 
             _libraryManager.ItemAdded -= OnLibraryManagerItemAdded;
             _appHost.HasPendingRestartChanged -= OnAppHostHasPendingRestartChanged;
-            _appHost.HasUpdateAvailableChanged -= OnAppHostHasUpdateAvailableChanged;
             _activityManager.EntryCreated -= OnActivityManagerEntryCreated;
 
             _disposed = true;

+ 1 - 5
Emby.Photos/Emby.Photos.csproj

@@ -19,13 +19,9 @@
   </ItemGroup>
 
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
-    <Nullable>enable</Nullable>
-    <AnalysisMode>AllEnabledByDefault</AnalysisMode>
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
   </PropertyGroup>
 
   <!-- Code Analyzers-->

+ 2 - 1
Emby.Photos/PhotoProvider.cs

@@ -3,6 +3,7 @@ using System.IO;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Extensions;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
@@ -60,7 +61,7 @@ namespace Emby.Photos
             item.SetImagePath(ImageType.Primary, item.Path);
 
             // Examples: https://github.com/mono/taglib-sharp/blob/a5f6949a53d09ce63ee7495580d6802921a21f14/tests/fixtures/TagLib.Tests.Images/NullOrientationTest.cs
-            if (_includeExtensions.Contains(Path.GetExtension(item.Path), StringComparer.OrdinalIgnoreCase))
+            if (_includeExtensions.Contains(Path.GetExtension(item.Path), StringComparison.OrdinalIgnoreCase))
             {
                 try
                 {

+ 1 - 1
Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs

@@ -301,7 +301,7 @@ namespace Emby.Server.Implementations.AppBase
         {
             return _configurations.GetOrAdd(
                 key,
-                (k, configurationManager) =>
+                static (k, configurationManager) =>
                 {
                     var file = configurationManager.GetConfigurationFile(k);
 

+ 4 - 6
Emby.Server.Implementations/AppBase/ConfigurationHelper.cs

@@ -1,6 +1,5 @@
 using System;
 using System.IO;
-using System.Linq;
 using MediaBrowser.Model.Serialization;
 
 namespace Emby.Server.Implementations.AppBase
@@ -41,20 +40,19 @@ namespace Emby.Server.Implementations.AppBase
             xmlSerializer.SerializeToStream(configuration, stream);
 
             // Take the object we just got and serialize it back to bytes
-            byte[] newBytes = stream.GetBuffer();
-            int newBytesLen = (int)stream.Length;
+            Span<byte> newBytes = stream.GetBuffer().AsSpan(0, (int)stream.Length);
 
             // If the file didn't exist before, or if something has changed, re-save
-            if (buffer == null || !newBytes.AsSpan(0, newBytesLen).SequenceEqual(buffer))
+            if (buffer == null || !newBytes.SequenceEqual(buffer))
             {
                 var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path));
 
                 Directory.CreateDirectory(directory);
+
                 // Save it after load in case we got new items
-                // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 .
                 using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
                 {
-                    fs.Write(newBytes, 0, newBytesLen);
+                    fs.Write(newBytes);
                 }
             }
 

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 236 - 379
Emby.Server.Implementations/ApplicationHost.cs


+ 1 - 112
Emby.Server.Implementations/Archiving/ZipClient.cs

@@ -1,11 +1,8 @@
 using System.IO;
 using MediaBrowser.Model.IO;
-using SharpCompress.Archives.SevenZip;
-using SharpCompress.Archives.Tar;
 using SharpCompress.Common;
 using SharpCompress.Readers;
 using SharpCompress.Readers.GZip;
-using SharpCompress.Readers.Zip;
 
 namespace Emby.Server.Implementations.Archiving
 {
@@ -14,53 +11,6 @@ namespace Emby.Server.Implementations.Archiving
     /// </summary>
     public class ZipClient : IZipClient
     {
-        /// <summary>
-        /// Extracts all.
-        /// </summary>
-        /// <param name="sourceFile">The source file.</param>
-        /// <param name="targetPath">The target path.</param>
-        /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param>
-        public void ExtractAll(string sourceFile, string targetPath, bool overwriteExistingFiles)
-        {
-            using var fileStream = File.OpenRead(sourceFile);
-            ExtractAll(fileStream, targetPath, overwriteExistingFiles);
-        }
-
-        /// <summary>
-        /// Extracts all.
-        /// </summary>
-        /// <param name="source">The source.</param>
-        /// <param name="targetPath">The target path.</param>
-        /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param>
-        public void ExtractAll(Stream source, string targetPath, bool overwriteExistingFiles)
-        {
-            using var reader = ReaderFactory.Open(source);
-            var options = new ExtractionOptions
-            {
-                ExtractFullPath = true
-            };
-
-            if (overwriteExistingFiles)
-            {
-                options.Overwrite = true;
-            }
-
-            reader.WriteAllToDirectory(targetPath, options);
-        }
-
-        /// <inheritdoc />
-        public void ExtractAllFromZip(Stream source, string targetPath, bool overwriteExistingFiles)
-        {
-            using var reader = ZipReader.Open(source);
-            var options = new ExtractionOptions
-            {
-                ExtractFullPath = true,
-                Overwrite = overwriteExistingFiles
-            };
-
-            reader.WriteAllToDirectory(targetPath, options);
-        }
-
         /// <inheritdoc />
         public void ExtractAllFromGz(Stream source, string targetPath, bool overwriteExistingFiles)
         {
@@ -71,6 +21,7 @@ namespace Emby.Server.Implementations.Archiving
                 Overwrite = overwriteExistingFiles
             };
 
+            Directory.CreateDirectory(targetPath);
             reader.WriteAllToDirectory(targetPath, options);
         }
 
@@ -91,67 +42,5 @@ namespace Emby.Server.Implementations.Archiving
                 reader.WriteEntryToFile(Path.Combine(targetPath, filename));
             }
         }
-
-        /// <summary>
-        /// Extracts all from7z.
-        /// </summary>
-        /// <param name="sourceFile">The source file.</param>
-        /// <param name="targetPath">The target path.</param>
-        /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param>
-        public void ExtractAllFrom7z(string sourceFile, string targetPath, bool overwriteExistingFiles)
-        {
-            using var fileStream = File.OpenRead(sourceFile);
-            ExtractAllFrom7z(fileStream, targetPath, overwriteExistingFiles);
-        }
-
-        /// <summary>
-        /// Extracts all from7z.
-        /// </summary>
-        /// <param name="source">The source.</param>
-        /// <param name="targetPath">The target path.</param>
-        /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param>
-        public void ExtractAllFrom7z(Stream source, string targetPath, bool overwriteExistingFiles)
-        {
-            using var archive = SevenZipArchive.Open(source);
-            using var reader = archive.ExtractAllEntries();
-            var options = new ExtractionOptions
-            {
-                ExtractFullPath = true,
-                Overwrite = overwriteExistingFiles
-            };
-
-            reader.WriteAllToDirectory(targetPath, options);
-        }
-
-        /// <summary>
-        /// Extracts all from tar.
-        /// </summary>
-        /// <param name="sourceFile">The source file.</param>
-        /// <param name="targetPath">The target path.</param>
-        /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param>
-        public void ExtractAllFromTar(string sourceFile, string targetPath, bool overwriteExistingFiles)
-        {
-            using var fileStream = File.OpenRead(sourceFile);
-            ExtractAllFromTar(fileStream, targetPath, overwriteExistingFiles);
-        }
-
-        /// <summary>
-        /// Extracts all from tar.
-        /// </summary>
-        /// <param name="source">The source.</param>
-        /// <param name="targetPath">The target path.</param>
-        /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param>
-        public void ExtractAllFromTar(Stream source, string targetPath, bool overwriteExistingFiles)
-        {
-            using var archive = TarArchive.Open(source);
-            using var reader = archive.ExtractAllEntries();
-            var options = new ExtractionOptions
-            {
-                ExtractFullPath = true,
-                Overwrite = overwriteExistingFiles
-            };
-
-            reader.WriteAllToDirectory(targetPath, options);
-        }
     }
 }

+ 11 - 20
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -10,8 +10,9 @@ using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
+using Jellyfin.Extensions;
+using Jellyfin.Extensions.Json;
 using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Progress;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Configuration;
@@ -102,7 +103,7 @@ namespace Emby.Server.Implementations.Channels
             var internalChannel = _libraryManager.GetItemById(item.ChannelId);
             var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id));
 
-            return !(channel is IDisableMediaSourceDisplay);
+            return channel is not IDisableMediaSourceDisplay;
         }
 
         /// <inheritdoc />
@@ -179,7 +180,7 @@ namespace Emby.Server.Implementations.Channels
                     try
                     {
                         return (GetChannelProvider(i) is IHasFolderAttributes hasAttributes
-                            && hasAttributes.Attributes.Contains("Recordings", StringComparer.OrdinalIgnoreCase)) == val;
+                            && hasAttributes.Attributes.Contains("Recordings", StringComparison.OrdinalIgnoreCase)) == val;
                     }
                     catch
                     {
@@ -541,7 +542,7 @@ namespace Emby.Server.Implementations.Channels
             return _libraryManager.GetItemIds(
                 new InternalItemsQuery
                 {
-                    IncludeItemTypes = new[] { nameof(Channel) },
+                    IncludeItemTypes = new[] { BaseItemKind.Channel },
                     OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
                 }).Select(i => GetChannelFeatures(i)).ToArray();
         }
@@ -586,7 +587,7 @@ namespace Emby.Server.Implementations.Channels
         {
             var supportsLatest = provider is ISupportsLatestMedia;
 
-            return new ChannelFeatures
+            return new ChannelFeatures(channel.Name, channel.Id)
             {
                 CanFilter = !features.MaxPageSize.HasValue,
                 CanSearch = provider is ISearchableChannel,
@@ -596,8 +597,6 @@ namespace Emby.Server.Implementations.Channels
                 MediaTypes = features.MediaTypes.ToArray(),
                 SupportsSortOrderToggle = features.SupportsSortOrderToggle,
                 SupportsLatestMedia = supportsLatest,
-                Name = channel.Name,
-                Id = channel.Id.ToString("N", CultureInfo.InvariantCulture),
                 SupportsContentDownloading = features.SupportsContentDownloading,
                 AutoRefreshLevels = features.AutoRefreshLevels
             };
@@ -815,7 +814,7 @@ namespace Emby.Server.Implementations.Channels
             {
                 if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow)
                 {
-                    await using FileStream jsonStream = File.OpenRead(cachePath);
+                    await using FileStream jsonStream = AsyncFile.OpenRead(cachePath);
                     var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
                     if (cachedResult != null)
                     {
@@ -838,7 +837,7 @@ namespace Emby.Server.Implementations.Channels
                 {
                     if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow)
                     {
-                        await using FileStream jsonStream = File.OpenRead(cachePath);
+                        await using FileStream jsonStream = AsyncFile.OpenRead(cachePath);
                         var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
                         if (cachedResult != null)
                         {
@@ -880,7 +879,7 @@ namespace Emby.Server.Implementations.Channels
             }
         }
 
-        private async Task CacheResponse(object result, string path)
+        private async Task CacheResponse(ChannelItemResult result, string path)
         {
             try
             {
@@ -1077,14 +1076,6 @@ namespace Emby.Server.Implementations.Channels
                 forceUpdate = true;
             }
 
-            // was used for status
-            // if (!string.Equals(item.ExternalEtag ?? string.Empty, info.Etag ?? string.Empty, StringComparison.Ordinal))
-            //{
-            //    item.ExternalEtag = info.Etag;
-            //    forceUpdate = true;
-            //    _logger.LogDebug("Forcing update due to ExternalEtag {0}", item.Name);
-            //}
-
             if (!internalChannelId.Equals(item.ChannelId))
             {
                 forceUpdate = true;
@@ -1145,7 +1136,7 @@ namespace Emby.Server.Implementations.Channels
 
             if (!info.IsLiveStream)
             {
-                if (item.Tags.Contains("livestream", StringComparer.OrdinalIgnoreCase))
+                if (item.Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase))
                 {
                     item.Tags = item.Tags.Except(new[] { "livestream" }, StringComparer.OrdinalIgnoreCase).ToArray();
                     _logger.LogDebug("Forcing update due to Tags {0}", item.Name);
@@ -1154,7 +1145,7 @@ namespace Emby.Server.Implementations.Channels
             }
             else
             {
-                if (!item.Tags.Contains("livestream", StringComparer.OrdinalIgnoreCase))
+                if (!item.Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase))
                 {
                     item.Tags = item.Tags.Concat(new[] { "livestream" }).ToArray();
                     _logger.LogDebug("Forcing update due to Tags {0}", item.Name);

+ 2 - 1
Emby.Server.Implementations/Channels/ChannelPostScanTask.cs

@@ -2,6 +2,7 @@ using System;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
@@ -51,7 +52,7 @@ namespace Emby.Server.Implementations.Channels
 
             var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery
             {
-                IncludeItemTypes = new[] { nameof(Channel) },
+                IncludeItemTypes = new[] { BaseItemKind.Channel },
                 ExcludeItemIds = installedChannelIds.ToArray()
             });
 

+ 20 - 28
Emby.Server.Implementations/Collections/CollectionManager.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.IO;
@@ -63,13 +61,13 @@ namespace Emby.Server.Implementations.Collections
         }
 
         /// <inheritdoc />
-        public event EventHandler<CollectionCreatedEventArgs> CollectionCreated;
+        public event EventHandler<CollectionCreatedEventArgs>? CollectionCreated;
 
         /// <inheritdoc />
-        public event EventHandler<CollectionModifiedEventArgs> ItemsAddedToCollection;
+        public event EventHandler<CollectionModifiedEventArgs>? ItemsAddedToCollection;
 
         /// <inheritdoc />
-        public event EventHandler<CollectionModifiedEventArgs> ItemsRemovedFromCollection;
+        public event EventHandler<CollectionModifiedEventArgs>? ItemsRemovedFromCollection;
 
         private IEnumerable<Folder> FindFolders(string path)
         {
@@ -80,14 +78,12 @@ namespace Emby.Server.Implementations.Collections
                 .Where(i => _fileSystem.AreEqual(path, i.Path) || _fileSystem.ContainsSubPath(i.Path, path));
         }
 
-        internal async Task<Folder> EnsureLibraryFolder(string path, bool createIfNeeded)
+        internal async Task<Folder?> EnsureLibraryFolder(string path, bool createIfNeeded)
         {
-            var existingFolders = FindFolders(path)
-                .ToList();
-
-            if (existingFolders.Count > 0)
+            var existingFolder = FindFolders(path).FirstOrDefault();
+            if (existingFolder != null)
             {
-                return existingFolders[0];
+                return existingFolder;
             }
 
             if (!createIfNeeded)
@@ -99,7 +95,7 @@ namespace Emby.Server.Implementations.Collections
 
             var libraryOptions = new LibraryOptions
             {
-                PathInfos = new[] { new MediaPathInfo { Path = path } },
+                PathInfos = new[] { new MediaPathInfo(path) },
                 EnableRealtimeMonitor = false,
                 SaveLocalMetadata = true
             };
@@ -116,7 +112,7 @@ namespace Emby.Server.Implementations.Collections
             return Path.Combine(_appPaths.DataPath, "collections");
         }
 
-        private Task<Folder> GetCollectionsFolder(bool createIfNeeded)
+        private Task<Folder?> GetCollectionsFolder(bool createIfNeeded)
         {
             return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
         }
@@ -164,7 +160,7 @@ namespace Emby.Server.Implementations.Collections
                     DateCreated = DateTime.UtcNow
                 };
 
-                parentFolder.AddChild(collection, CancellationToken.None);
+                parentFolder.AddChild(collection);
 
                 if (options.ItemIdList.Count > 0)
                 {
@@ -200,13 +196,12 @@ namespace Emby.Server.Implementations.Collections
         }
 
         /// <inheritdoc />
-        public Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> ids)
-            => AddToCollectionAsync(collectionId, ids, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem)));
+        public Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds)
+            => AddToCollectionAsync(collectionId, itemIds, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem)));
 
         private async Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> ids, bool fireEvent, MetadataRefreshOptions refreshOptions)
         {
-            var collection = _libraryManager.GetItemById(collectionId) as BoxSet;
-            if (collection == null)
+            if (_libraryManager.GetItemById(collectionId) is not BoxSet collection)
             {
                 throw new ArgumentException("No collection exists with the supplied Id");
             }
@@ -258,9 +253,7 @@ namespace Emby.Server.Implementations.Collections
         /// <inheritdoc />
         public async Task RemoveFromCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds)
         {
-            var collection = _libraryManager.GetItemById(collectionId) as BoxSet;
-
-            if (collection == null)
+            if (_libraryManager.GetItemById(collectionId) is not BoxSet collection)
             {
                 throw new ArgumentException("No collection exists with the supplied Id");
             }
@@ -314,11 +307,7 @@ namespace Emby.Server.Implementations.Collections
 
             foreach (var item in items)
             {
-                if (item is not ISupportsBoxSetGrouping)
-                {
-                    results[item.Id] = item;
-                }
-                else
+                if (item is ISupportsBoxSetGrouping)
                 {
                     var itemId = item.Id;
 
@@ -342,6 +331,7 @@ namespace Emby.Server.Implementations.Collections
                     }
 
                     var alreadyInResults = false;
+
                     // this is kind of a performance hack because only Video has alternate versions that should be in a box set?
                     if (item is Video video)
                     {
@@ -357,11 +347,13 @@ namespace Emby.Server.Implementations.Collections
                         }
                     }
 
-                    if (!alreadyInResults)
+                    if (alreadyInResults)
                     {
-                        results[itemId] = item;
+                        continue;
                     }
                 }
+
+                results[item.Id] = item;
             }
 
             return results.Values;

+ 52 - 74
Emby.Server.Implementations/Cryptography/CryptographyProvider.cs

@@ -1,17 +1,20 @@
 using System;
 using System.Collections.Generic;
+using System.Globalization;
 using System.Security.Cryptography;
+using System.Text;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Model.Cryptography;
-using static MediaBrowser.Common.Cryptography.Constants;
+using static MediaBrowser.Model.Cryptography.Constants;
 
 namespace Emby.Server.Implementations.Cryptography
 {
     /// <summary>
     /// Class providing abstractions over cryptographic functions.
     /// </summary>
-    public class CryptographyProvider : ICryptoProvider, IDisposable
+    public class CryptographyProvider : ICryptoProvider
     {
+        // TODO: remove when not needed for backwards compat
         private static readonly HashSet<string> _supportedHashMethods = new HashSet<string>()
             {
                 "MD5",
@@ -30,71 +33,71 @@ namespace Emby.Server.Implementations.Cryptography
                 "System.Security.Cryptography.SHA512"
             };
 
-        private RandomNumberGenerator _randomNumberGenerator;
-
-        private bool _disposed;
+        /// <inheritdoc />
+        public string DefaultHashMethod => "PBKDF2-SHA512";
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CryptographyProvider"/> class.
-        /// </summary>
-        public CryptographyProvider()
+        /// <inheritdoc />
+        public PasswordHash CreatePasswordHash(ReadOnlySpan<char> password)
         {
-            // FIXME: When we get DotNet Standard 2.1 we need to revisit how we do the crypto
-            // Currently supported hash methods from https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.cryptoconfig?view=netcore-2.1
-            // there might be a better way to autogenerate this list as dotnet updates, but I couldn't find one
-            // Please note the default method of PBKDF2 is not included, it cannot be used to generate hashes cleanly as it is actually a pbkdf with sha1
-            _randomNumberGenerator = RandomNumberGenerator.Create();
+            byte[] salt = GenerateSalt();
+            return new PasswordHash(
+                DefaultHashMethod,
+                Rfc2898DeriveBytes.Pbkdf2(
+                    password,
+                    salt,
+                    DefaultIterations,
+                    HashAlgorithmName.SHA512,
+                    DefaultOutputLength),
+                salt,
+                new Dictionary<string, string>
+                {
+                    { "iterations", DefaultIterations.ToString(CultureInfo.InvariantCulture) }
+                });
         }
 
         /// <inheritdoc />
-        public string DefaultHashMethod => "PBKDF2";
-
-        /// <inheritdoc />
-        public IEnumerable<string> GetSupportedHashMethods()
-            => _supportedHashMethods;
-
-        private byte[] PBKDF2(string method, byte[] bytes, byte[] salt, int iterations)
+        public bool Verify(PasswordHash hash, ReadOnlySpan<char> password)
         {
-            // downgrading for now as we need this library to be dotnetstandard compliant
-            // with this downgrade we'll add a check to make sure we're on the downgrade method at the moment
-            if (method != DefaultHashMethod)
+            if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
             {
-                throw new CryptographicException($"Cannot currently use PBKDF2 with requested hash method: {method}");
+                return hash.Hash.SequenceEqual(
+                    Rfc2898DeriveBytes.Pbkdf2(
+                        password,
+                        hash.Salt,
+                        int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
+                        HashAlgorithmName.SHA1,
+                        32));
             }
 
-            using var r = new Rfc2898DeriveBytes(bytes, salt, iterations);
-            return r.GetBytes(32);
-        }
-
-        /// <inheritdoc />
-        public byte[] ComputeHash(string hashMethod, byte[] bytes, byte[] salt)
-        {
-            if (hashMethod == DefaultHashMethod)
+            if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
             {
-                return PBKDF2(hashMethod, bytes, salt, DefaultIterations);
+                return hash.Hash.SequenceEqual(
+                    Rfc2898DeriveBytes.Pbkdf2(
+                        password,
+                        hash.Salt,
+                        int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
+                        HashAlgorithmName.SHA512,
+                        DefaultOutputLength));
             }
 
-            if (!_supportedHashMethods.Contains(hashMethod))
+            if (!_supportedHashMethods.Contains(hash.Id))
             {
-                throw new CryptographicException($"Requested hash method is not supported: {hashMethod}");
+                throw new CryptographicException($"Requested hash method is not supported: {hash.Id}");
             }
 
-            using var h = HashAlgorithm.Create(hashMethod) ?? throw new ResourceNotFoundException($"Unknown hash method: {hashMethod}.");
-            if (salt.Length == 0)
+            using var h = HashAlgorithm.Create(hash.Id) ?? throw new ResourceNotFoundException($"Unknown hash method: {hash.Id}.");
+            var bytes = Encoding.UTF8.GetBytes(password.ToArray());
+            if (hash.Salt.Length == 0)
             {
-                return h.ComputeHash(bytes);
+                return hash.Hash.SequenceEqual(h.ComputeHash(bytes));
             }
 
-            byte[] salted = new byte[bytes.Length + salt.Length];
+            byte[] salted = new byte[bytes.Length + hash.Salt.Length];
             Array.Copy(bytes, salted, bytes.Length);
-            Array.Copy(salt, 0, salted, bytes.Length, salt.Length);
-            return h.ComputeHash(salted);
+            hash.Salt.CopyTo(salted.AsSpan(bytes.Length));
+            return hash.Hash.SequenceEqual(h.ComputeHash(salted));
         }
 
-        /// <inheritdoc />
-        public byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt)
-            => PBKDF2(DefaultHashMethod, bytes, salt, DefaultIterations);
-
         /// <inheritdoc />
         public byte[] GenerateSalt()
             => GenerateSalt(DefaultSaltLength);
@@ -102,35 +105,10 @@ namespace Emby.Server.Implementations.Cryptography
         /// <inheritdoc />
         public byte[] GenerateSalt(int length)
         {
-            byte[] salt = new byte[length];
-            _randomNumberGenerator.GetBytes(salt);
+            var salt = new byte[length];
+            using var rng = RandomNumberGenerator.Create();
+            rng.GetNonZeroBytes(salt);
             return salt;
         }
-
-        /// <inheritdoc />
-        public void Dispose()
-        {
-            Dispose(true);
-            GC.SuppressFinalize(this);
-        }
-
-        /// <summary>
-        /// Releases unmanaged and - optionally - managed resources.
-        /// </summary>
-        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
-        protected virtual void Dispose(bool disposing)
-        {
-            if (_disposed)
-            {
-                return;
-            }
-
-            if (disposing)
-            {
-                _randomNumberGenerator.Dispose();
-            }
-
-            _disposed = true;
-        }
     }
 }

+ 4 - 55
Emby.Server.Implementations/Data/BaseSqliteRepository.cs

@@ -4,8 +4,8 @@
 
 using System;
 using System.Collections.Generic;
-using System.Linq;
 using System.Threading;
+using Jellyfin.Extensions;
 using Microsoft.Extensions.Logging;
 using SQLitePCL.pretty;
 
@@ -61,7 +61,7 @@ namespace Emby.Server.Implementations.Data
         protected virtual int? CacheSize => null;
 
         /// <summary>
-        /// Gets the journal mode. <see href="https://www.sqlite.org/pragma.html#pragma_journal_mode" />
+        /// Gets the journal mode. <see href="https://www.sqlite.org/pragma.html#pragma_journal_mode" />.
         /// </summary>
         /// <value>The journal mode.</value>
         protected virtual string JournalMode => "TRUNCATE";
@@ -98,7 +98,7 @@ namespace Emby.Server.Implementations.Data
         /// <value>The write connection.</value>
         protected SQLiteDatabaseConnection WriteConnection { get; set; }
 
-        protected ManagedConnection GetConnection(bool _ = false)
+        protected ManagedConnection GetConnection(bool readOnly = false)
         {
             WriteLock.Wait();
             if (WriteConnection != null)
@@ -194,7 +194,7 @@ namespace Emby.Server.Implementations.Data
 
         protected void AddColumn(IDatabaseConnection connection, string table, string columnName, string type, List<string> existingColumnNames)
         {
-            if (existingColumnNames.Contains(columnName, StringComparer.OrdinalIgnoreCase))
+            if (existingColumnNames.Contains(columnName, StringComparison.OrdinalIgnoreCase))
             {
                 return;
             }
@@ -249,55 +249,4 @@ namespace Emby.Server.Implementations.Data
             _disposed = true;
         }
     }
-
-    /// <summary>
-    /// The disk synchronization mode, controls how aggressively SQLite will write data
-    /// all the way out to physical storage.
-    /// </summary>
-    public enum SynchronousMode
-    {
-        /// <summary>
-        /// SQLite continues without syncing as soon as it has handed data off to the operating system.
-        /// </summary>
-        Off = 0,
-
-        /// <summary>
-        /// SQLite database engine will still sync at the most critical moments.
-        /// </summary>
-        Normal = 1,
-
-        /// <summary>
-        /// SQLite database engine will use the xSync method of the VFS
-        /// to ensure that all content is safely written to the disk surface prior to continuing.
-        /// </summary>
-        Full = 2,
-
-        /// <summary>
-        /// EXTRA synchronous is like FULL with the addition that the directory containing a rollback journal
-        /// is synced after that journal is unlinked to commit a transaction in DELETE mode.
-        /// </summary>
-        Extra = 3
-    }
-
-    /// <summary>
-    /// Storage mode used by temporary database files.
-    /// </summary>
-    public enum TempStoreMode
-    {
-        /// <summary>
-        /// The compile-time C preprocessor macro SQLITE_TEMP_STORE
-        /// is used to determine where temporary tables and indices are stored.
-        /// </summary>
-        Default = 0,
-
-        /// <summary>
-        /// Temporary tables and indices are stored in a file.
-        /// </summary>
-        File = 1,
-
-        /// <summary>
-        /// Temporary tables and indices are kept in as if they were pure in-memory databases memory.
-        /// </summary>
-        Memory = 2
-    }
 }

+ 4 - 2
Emby.Server.Implementations/Data/ManagedConnection.cs

@@ -7,10 +7,12 @@ using SQLitePCL.pretty;
 
 namespace Emby.Server.Implementations.Data
 {
-    public class ManagedConnection : IDisposable
+    public sealed class ManagedConnection : IDisposable
     {
-        private SQLiteDatabaseConnection? _db;
         private readonly SemaphoreSlim _writeLock;
+
+        private SQLiteDatabaseConnection? _db;
+
         private bool _disposed = false;
 
         public ManagedConnection(SQLiteDatabaseConnection db, SemaphoreSlim writeLock)

+ 3 - 3
Emby.Server.Implementations/Data/SqliteExtensions.cs

@@ -94,7 +94,7 @@ namespace Emby.Server.Implementations.Data
                 dateText,
                 _datetimeFormats,
                 DateTimeFormatInfo.InvariantInfo,
-                DateTimeStyles.None).ToUniversalTime();
+                DateTimeStyles.AdjustToUniversal);
         }
 
         public static bool TryReadDateTime(this IReadOnlyList<ResultSetValue> reader, int index, out DateTime result)
@@ -108,9 +108,9 @@ namespace Emby.Server.Implementations.Data
 
             var dateText = item.ToString();
 
-            if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.None, out var dateTimeResult))
+            if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AdjustToUniversal, out var dateTimeResult))
             {
-                result = dateTimeResult.ToUniversalTime();
+                result = dateTimeResult;
                 return true;
             }
 

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 399 - 309
Emby.Server.Implementations/Data/SqliteItemRepository.cs


+ 31 - 29
Emby.Server.Implementations/Data/SqliteUserDataRepository.cs

@@ -32,6 +32,9 @@ namespace Emby.Server.Implementations.Data
         /// <summary>
         /// Opens the connection to the database.
         /// </summary>
+        /// <param name="userManager">The user manager.</param>
+        /// <param name="dbLock">The lock to use for database IO.</param>
+        /// <param name="dbConnection">The connection to use for database IO.</param>
         public void Initialize(IUserManager userManager, SemaphoreSlim dbLock, SQLiteDatabaseConnection dbConnection)
         {
             WriteLock.Dispose();
@@ -49,8 +52,8 @@ namespace Emby.Server.Implementations.Data
                 connection.RunInTransaction(
                 db =>
                 {
-                    db.ExecuteAll(string.Join(';', new[] {
-
+                    db.ExecuteAll(string.Join(';', new[]
+                    {
                         "create table if not exists UserDatas (key nvarchar not null, userId INT not null, rating float null, played bit not null, playCount int not null, isFavorite bit not null, playbackPositionTicks bigint not null, lastPlayedDate datetime null, AudioStreamIndex INT, SubtitleStreamIndex INT)",
 
                         "drop index if exists idx_userdata",
@@ -129,19 +132,17 @@ namespace Emby.Server.Implementations.Data
             return list;
         }
 
-        /// <summary>
-        /// Saves the user data.
-        /// </summary>
-        public void SaveUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken)
+        /// <inheritdoc />
+        public void SaveUserData(long userId, string key, UserItemData userData, CancellationToken cancellationToken)
         {
             if (userData == null)
             {
                 throw new ArgumentNullException(nameof(userData));
             }
 
-            if (internalUserId <= 0)
+            if (userId <= 0)
             {
-                throw new ArgumentNullException(nameof(internalUserId));
+                throw new ArgumentNullException(nameof(userId));
             }
 
             if (string.IsNullOrEmpty(key))
@@ -149,22 +150,23 @@ namespace Emby.Server.Implementations.Data
                 throw new ArgumentNullException(nameof(key));
             }
 
-            PersistUserData(internalUserId, key, userData, cancellationToken);
+            PersistUserData(userId, key, userData, cancellationToken);
         }
 
-        public void SaveAllUserData(long internalUserId, UserItemData[] userData, CancellationToken cancellationToken)
+        /// <inheritdoc />
+        public void SaveAllUserData(long userId, UserItemData[] userData, CancellationToken cancellationToken)
         {
             if (userData == null)
             {
                 throw new ArgumentNullException(nameof(userData));
             }
 
-            if (internalUserId <= 0)
+            if (userId <= 0)
             {
-                throw new ArgumentNullException(nameof(internalUserId));
+                throw new ArgumentNullException(nameof(userId));
             }
 
-            PersistAllUserData(internalUserId, userData, cancellationToken);
+            PersistAllUserData(userId, userData, cancellationToken);
         }
 
         /// <summary>
@@ -174,7 +176,6 @@ namespace Emby.Server.Implementations.Data
         /// <param name="key">The key.</param>
         /// <param name="userData">The user data.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <returns>Task.</returns>
         public void PersistUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken)
         {
             cancellationToken.ThrowIfCancellationRequested();
@@ -264,19 +265,19 @@ namespace Emby.Server.Implementations.Data
         /// <summary>
         /// Gets the user data.
         /// </summary>
-        /// <param name="internalUserId">The user id.</param>
+        /// <param name="userId">The user id.</param>
         /// <param name="key">The key.</param>
         /// <returns>Task{UserItemData}.</returns>
         /// <exception cref="ArgumentNullException">
         /// userId
         /// or
-        /// key
+        /// key.
         /// </exception>
-        public UserItemData GetUserData(long internalUserId, string key)
+        public UserItemData GetUserData(long userId, string key)
         {
-            if (internalUserId <= 0)
+            if (userId <= 0)
             {
-                throw new ArgumentNullException(nameof(internalUserId));
+                throw new ArgumentNullException(nameof(userId));
             }
 
             if (string.IsNullOrEmpty(key))
@@ -288,7 +289,7 @@ namespace Emby.Server.Implementations.Data
             {
                 using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId"))
                 {
-                    statement.TryBind("@UserId", internalUserId);
+                    statement.TryBind("@UserId", userId);
                     statement.TryBind("@Key", key);
 
                     foreach (var row in statement.ExecuteQuery())
@@ -301,7 +302,7 @@ namespace Emby.Server.Implementations.Data
             }
         }
 
-        public UserItemData GetUserData(long internalUserId, List<string> keys)
+        public UserItemData GetUserData(long userId, List<string> keys)
         {
             if (keys == null)
             {
@@ -313,19 +314,19 @@ namespace Emby.Server.Implementations.Data
                 return null;
             }
 
-            return GetUserData(internalUserId, keys[0]);
+            return GetUserData(userId, keys[0]);
         }
 
         /// <summary>
         /// Return all user-data associated with the given user.
         /// </summary>
-        /// <param name="internalUserId"></param>
-        /// <returns></returns>
-        public List<UserItemData> GetAllUserData(long internalUserId)
+        /// <param name="userId">The internal user id.</param>
+        /// <returns>The list of user item data.</returns>
+        public List<UserItemData> GetAllUserData(long userId)
         {
-            if (internalUserId <= 0)
+            if (userId <= 0)
             {
-                throw new ArgumentNullException(nameof(internalUserId));
+                throw new ArgumentNullException(nameof(userId));
             }
 
             var list = new List<UserItemData>();
@@ -334,7 +335,7 @@ namespace Emby.Server.Implementations.Data
             {
                 using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where userId=@UserId"))
                 {
-                    statement.TryBind("@UserId", internalUserId);
+                    statement.TryBind("@UserId", userId);
 
                     foreach (var row in statement.ExecuteQuery())
                     {
@@ -349,7 +350,8 @@ namespace Emby.Server.Implementations.Data
         /// <summary>
         /// Read a row from the specified reader into the provided userData object.
         /// </summary>
-        /// <param name="reader"></param>
+        /// <param name="reader">The list of result set values.</param>
+        /// <returns>The user item data.</returns>
         private UserItemData ReadRow(IReadOnlyList<ResultSetValue> reader)
         {
             var userData = new UserItemData();

+ 30 - 0
Emby.Server.Implementations/Data/SynchronouseMode.cs

@@ -0,0 +1,30 @@
+namespace Emby.Server.Implementations.Data;
+
+/// <summary>
+/// The disk synchronization mode, controls how aggressively SQLite will write data
+/// all the way out to physical storage.
+/// </summary>
+public enum SynchronousMode
+{
+    /// <summary>
+    /// SQLite continues without syncing as soon as it has handed data off to the operating system.
+    /// </summary>
+    Off = 0,
+
+    /// <summary>
+    /// SQLite database engine will still sync at the most critical moments.
+    /// </summary>
+    Normal = 1,
+
+    /// <summary>
+    /// SQLite database engine will use the xSync method of the VFS
+    /// to ensure that all content is safely written to the disk surface prior to continuing.
+    /// </summary>
+    Full = 2,
+
+    /// <summary>
+    /// EXTRA synchronous is like FULL with the addition that the directory containing a rollback journal
+    /// is synced after that journal is unlinked to commit a transaction in DELETE mode.
+    /// </summary>
+    Extra = 3
+}

+ 23 - 0
Emby.Server.Implementations/Data/TempStoreMode.cs

@@ -0,0 +1,23 @@
+namespace Emby.Server.Implementations.Data;
+
+/// <summary>
+/// Storage mode used by temporary database files.
+/// </summary>
+public enum TempStoreMode
+{
+    /// <summary>
+    /// The compile-time C preprocessor macro SQLITE_TEMP_STORE
+    /// is used to determine where temporary tables and indices are stored.
+    /// </summary>
+    Default = 0,
+
+    /// <summary>
+    /// Temporary tables and indices are stored in a file.
+    /// </summary>
+    File = 1,
+
+    /// <summary>
+    /// Temporary tables and indices are kept in as if they were pure in-memory databases memory.
+    /// </summary>
+    Memory = 2
+}

+ 10 - 11
Emby.Server.Implementations/Devices/DeviceId.cs

@@ -15,9 +15,18 @@ namespace Emby.Server.Implementations.Devices
     {
         private readonly IApplicationPaths _appPaths;
         private readonly ILogger<DeviceId> _logger;
-
         private readonly object _syncLock = new object();
 
+        private string _id;
+
+        public DeviceId(IApplicationPaths appPaths, ILoggerFactory loggerFactory)
+        {
+            _appPaths = appPaths;
+            _logger = loggerFactory.CreateLogger<DeviceId>();
+        }
+
+        public string Value => _id ?? (_id = GetDeviceId());
+
         private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
 
         private string GetCachedId()
@@ -86,15 +95,5 @@ namespace Emby.Server.Implementations.Devices
 
             return id;
         }
-
-        private string _id;
-
-        public DeviceId(IApplicationPaths appPaths, ILoggerFactory loggerFactory)
-        {
-            _appPaths = appPaths;
-            _logger = loggerFactory.CreateLogger<DeviceId>();
-        }
-
-        public string Value => _id ?? (_id = GetDeviceId());
     }
 }

+ 0 - 146
Emby.Server.Implementations/Devices/DeviceManager.cs

@@ -1,146 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using Jellyfin.Data.Entities;
-using Jellyfin.Data.Enums;
-using Jellyfin.Data.Events;
-using MediaBrowser.Controller.Devices;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Security;
-using MediaBrowser.Model.Devices;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Session;
-
-namespace Emby.Server.Implementations.Devices
-{
-    public class DeviceManager : IDeviceManager
-    {
-        private readonly IUserManager _userManager;
-        private readonly IAuthenticationRepository _authRepo;
-        private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new ();
-
-        public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager)
-        {
-            _userManager = userManager;
-            _authRepo = authRepo;
-        }
-
-        public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
-
-        public void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
-        {
-            _capabilitiesMap[deviceId] = capabilities;
-        }
-
-        public void UpdateDeviceOptions(string deviceId, DeviceOptions options)
-        {
-            _authRepo.UpdateDeviceOptions(deviceId, options);
-
-            DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, DeviceOptions>(deviceId, options)));
-        }
-
-        public DeviceOptions GetDeviceOptions(string deviceId)
-        {
-            return _authRepo.GetDeviceOptions(deviceId);
-        }
-
-        public ClientCapabilities GetCapabilities(string id)
-        {
-            return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result)
-                ? result
-                : new ClientCapabilities();
-        }
-
-        public DeviceInfo GetDevice(string id)
-        {
-            var session = _authRepo.Get(new AuthenticationInfoQuery
-            {
-                DeviceId = id
-            }).Items.FirstOrDefault();
-
-            var device = session == null ? null : ToDeviceInfo(session);
-
-            return device;
-        }
-
-        public QueryResult<DeviceInfo> GetDevices(DeviceQuery query)
-        {
-            IEnumerable<AuthenticationInfo> sessions = _authRepo.Get(new AuthenticationInfoQuery
-            {
-                // UserId = query.UserId
-                HasUser = true
-            }).Items;
-
-            // TODO: DeviceQuery doesn't seem to be used from client. Not even Swagger.
-            if (query.SupportsSync.HasValue)
-            {
-                var val = query.SupportsSync.Value;
-
-                sessions = sessions.Where(i => GetCapabilities(i.DeviceId).SupportsSync == val);
-            }
-
-            if (!query.UserId.Equals(Guid.Empty))
-            {
-                var user = _userManager.GetUserById(query.UserId);
-
-                sessions = sessions.Where(i => CanAccessDevice(user, i.DeviceId));
-            }
-
-            var array = sessions.Select(ToDeviceInfo).ToArray();
-
-            return new QueryResult<DeviceInfo>(array);
-        }
-
-        private DeviceInfo ToDeviceInfo(AuthenticationInfo authInfo)
-        {
-            var caps = GetCapabilities(authInfo.DeviceId);
-
-            return new DeviceInfo
-            {
-                AppName = authInfo.AppName,
-                AppVersion = authInfo.AppVersion,
-                Id = authInfo.DeviceId,
-                LastUserId = authInfo.UserId,
-                LastUserName = authInfo.UserName,
-                Name = authInfo.DeviceName,
-                DateLastActivity = authInfo.DateLastActivity,
-                IconUrl = caps?.IconUrl
-            };
-        }
-
-        public bool CanAccessDevice(User user, string deviceId)
-        {
-            if (user == null)
-            {
-                throw new ArgumentException("user not found");
-            }
-
-            if (string.IsNullOrEmpty(deviceId))
-            {
-                throw new ArgumentNullException(nameof(deviceId));
-            }
-
-            if (user.HasPermission(PermissionKind.EnableAllDevices) || user.HasPermission(PermissionKind.IsAdministrator))
-            {
-                return true;
-            }
-
-            if (!user.GetPreference(PreferenceKind.EnabledDevices).Contains(deviceId, StringComparer.OrdinalIgnoreCase))
-            {
-                var capabilities = GetCapabilities(deviceId);
-
-                if (capabilities != null && capabilities.SupportsPersistentIdentifier)
-                {
-                    return false;
-                }
-            }
-
-            return true;
-        }
-    }
-}

+ 46 - 72
Emby.Server.Implementations/Dto/DtoService.cs

@@ -7,9 +7,9 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
 using System.Linq;
-using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
+using Jellyfin.Extensions;
 using MediaBrowser.Common;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Drawing;
@@ -51,8 +51,6 @@ namespace Emby.Server.Implementations.Dto
         private readonly IMediaSourceManager _mediaSourceManager;
         private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
 
-        private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
-
         public DtoService(
             ILogger<DtoService> logger,
             ILibraryManager libraryManager,
@@ -75,6 +73,8 @@ namespace Emby.Server.Implementations.Dto
             _livetvManagerFactory = livetvManagerFactory;
         }
 
+        private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
+
         /// <inheritdoc />
         public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User user = null, BaseItem owner = null)
         {
@@ -134,14 +134,11 @@ namespace Emby.Server.Implementations.Dto
             var dto = GetBaseItemDtoInternal(item, options, user, owner);
             if (item is LiveTvChannel tvChannel)
             {
-                var list = new List<(BaseItemDto, LiveTvChannel)>(1) { (dto, tvChannel) };
-                LivetvManager.AddChannelInfo(list, options, user);
+                LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user);
             }
             else if (item is LiveTvProgram)
             {
-                var list = new List<(BaseItem, BaseItemDto)>(1) { (item, dto) };
-                var task = LivetvManager.AddInfoToProgramDto(list, options.Fields, user);
-                Task.WaitAll(task);
+                LivetvManager.AddInfoToProgramDto(new[] { (item, dto) }, options.Fields, user).GetAwaiter().GetResult();
             }
 
             if (item is IItemByName itemByName
@@ -297,7 +294,7 @@ namespace Emby.Server.Implementations.Dto
                             path = path.TrimStart('.');
                         }
 
-                        if (!string.IsNullOrEmpty(path) && containers.Contains(path, StringComparer.OrdinalIgnoreCase))
+                        if (!string.IsNullOrEmpty(path) && containers.Contains(path, StringComparison.OrdinalIgnoreCase))
                         {
                             fileExtensionContainer = path;
                         }
@@ -373,6 +370,12 @@ namespace Emby.Server.Implementations.Dto
                     if (item is MusicAlbum || item is Season || item is Playlist)
                     {
                         dto.ChildCount = dto.RecursiveItemCount;
+                        var folderChildCount = folder.LinkedChildren.Length;
+                        // The default is an empty array, so we can't reliably use the count when it's empty
+                        if (folderChildCount > 0)
+                        {
+                            dto.ChildCount ??= folderChildCount;
+                        }
                     }
 
                     if (options.ContainsField(ItemFields.ChildCount))
@@ -420,7 +423,7 @@ namespace Emby.Server.Implementations.Dto
             // Just return something so that apps that are expecting a value won't think the folders are empty
             if (folder is ICollectionFolder || folder is UserView)
             {
-                return new Random().Next(1, 10);
+                return Random.Shared.Next(1, 10);
             }
 
             return folder.GetChildCount(user);
@@ -467,7 +470,7 @@ namespace Emby.Server.Implementations.Dto
             {
                 var parentAlbumIds = _libraryManager.GetItemIds(new InternalItemsQuery
                 {
-                    IncludeItemTypes = new[] { nameof(MusicAlbum) },
+                    IncludeItemTypes = new[] { BaseItemKind.MusicAlbum },
                     Name = item.Album,
                     Limit = 1
                 });
@@ -497,7 +500,7 @@ namespace Emby.Server.Implementations.Dto
             }
             catch (Exception ex)
             {
-                _logger.LogError(ex, "Error getting {imageType} image info for {path}", image.Type, image.Path);
+                _logger.LogError(ex, "Error getting {ImageType} image info for {Path}", image.Type, image.Path);
                 return null;
             }
         }
@@ -507,7 +510,6 @@ namespace Emby.Server.Implementations.Dto
         /// </summary>
         /// <param name="dto">The dto.</param>
         /// <param name="item">The item.</param>
-        /// <returns>Task.</returns>
         private void AttachPeople(BaseItemDto dto, BaseItem item)
         {
             // Ordering by person type to ensure actors and artists are at the front.
@@ -616,7 +618,6 @@ namespace Emby.Server.Implementations.Dto
         /// </summary>
         /// <param name="dto">The dto.</param>
         /// <param name="item">The item.</param>
-        /// <returns>Task.</returns>
         private void AttachStudios(BaseItemDto dto, BaseItem item)
         {
             dto.Studios = item.Studios
@@ -757,15 +758,6 @@ namespace Emby.Server.Implementations.Dto
                 dto.BackdropImageTags = GetTagsAndFillBlurhashes(dto, item, ImageType.Backdrop, backdropLimit);
             }
 
-            if (options.ContainsField(ItemFields.ScreenshotImageTags))
-            {
-                var screenshotLimit = options.GetImageLimit(ImageType.Screenshot);
-                if (screenshotLimit > 0)
-                {
-                    dto.ScreenshotImageTags = GetTagsAndFillBlurhashes(dto, item, ImageType.Screenshot, screenshotLimit);
-                }
-            }
-
             if (options.ContainsField(ItemFields.Genres))
             {
                 dto.Genres = item.Genres;
@@ -807,7 +799,7 @@ namespace Emby.Server.Implementations.Dto
 
             dto.MediaType = item.MediaType;
 
-            if (!(item is LiveTvProgram))
+            if (item is not LiveTvProgram)
             {
                 dto.LocationType = item.LocationType;
             }
@@ -928,9 +920,9 @@ namespace Emby.Server.Implementations.Dto
                 }
 
                 // if (options.ContainsField(ItemFields.MediaSourceCount))
-                //{
+                // {
                 // Songs always have one
-                //}
+                // }
             }
 
             if (item is IHasArtist hasArtist)
@@ -938,10 +930,10 @@ namespace Emby.Server.Implementations.Dto
                 dto.Artists = hasArtist.Artists;
 
                 // var artistItems = _libraryManager.GetArtists(new InternalItemsQuery
-                //{
+                // {
                 //    EnableTotalRecordCount = false,
                 //    ItemIds = new[] { item.Id.ToString("N", CultureInfo.InvariantCulture) }
-                //});
+                // });
 
                 // dto.ArtistItems = artistItems.Items
                 //    .Select(i =>
@@ -958,7 +950,7 @@ namespace Emby.Server.Implementations.Dto
                 // Include artists that are not in the database yet, e.g., just added via metadata editor
                 // var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
                 dto.ArtistItems = hasArtist.Artists
-                    //.Except(foundArtists, new DistinctNameComparer())
+                    // .Except(foundArtists, new DistinctNameComparer())
                     .Select(i =>
                     {
                         // This should not be necessary but we're seeing some cases of it
@@ -990,10 +982,10 @@ namespace Emby.Server.Implementations.Dto
                 dto.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
 
                 // var artistItems = _libraryManager.GetAlbumArtists(new InternalItemsQuery
-                //{
+                // {
                 //    EnableTotalRecordCount = false,
                 //    ItemIds = new[] { item.Id.ToString("N", CultureInfo.InvariantCulture) }
-                //});
+                // });
 
                 // dto.AlbumArtists = artistItems.Items
                 //    .Select(i =>
@@ -1008,7 +1000,7 @@ namespace Emby.Server.Implementations.Dto
                 //    .ToList();
 
                 dto.AlbumArtists = hasAlbumArtist.AlbumArtists
-                    //.Except(foundArtists, new DistinctNameComparer())
+                    // .Except(foundArtists, new DistinctNameComparer())
                     .Select(i =>
                     {
                         // This should not be necessary but we're seeing some cases of it
@@ -1035,8 +1027,7 @@ namespace Emby.Server.Implementations.Dto
             }
 
             // Add video info
-            var video = item as Video;
-            if (video != null)
+            if (item is Video video)
             {
                 dto.VideoType = video.VideoType;
                 dto.Video3DFormat = video.Video3DFormat;
@@ -1075,9 +1066,7 @@ namespace Emby.Server.Implementations.Dto
             if (options.ContainsField(ItemFields.MediaStreams))
             {
                 // Add VideoInfo
-                var iHasMediaSources = item as IHasMediaSources;
-
-                if (iHasMediaSources != null)
+                if (item is IHasMediaSources)
                 {
                     MediaStream[] mediaStreams;
 
@@ -1146,7 +1135,7 @@ namespace Emby.Server.Implementations.Dto
                 // TODO maybe remove the if statement entirely
                 // if (options.ContainsField(ItemFields.SeriesPrimaryImage))
                 {
-                    episodeSeries = episodeSeries ?? episode.Series;
+                    episodeSeries ??= episode.Series;
                     if (episodeSeries != null)
                     {
                         dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary);
@@ -1159,7 +1148,7 @@ namespace Emby.Server.Implementations.Dto
 
                 if (options.ContainsField(ItemFields.SeriesStudio))
                 {
-                    episodeSeries = episodeSeries ?? episode.Series;
+                    episodeSeries ??= episode.Series;
                     if (episodeSeries != null)
                     {
                         dto.SeriesStudio = episodeSeries.Studios.FirstOrDefault();
@@ -1172,7 +1161,7 @@ namespace Emby.Server.Implementations.Dto
             {
                 dto.AirDays = series.AirDays;
                 dto.AirTime = series.AirTime;
-                dto.Status = series.Status.HasValue ? series.Status.Value.ToString() : null;
+                dto.Status = series.Status?.ToString();
             }
 
             // Add SeasonInfo
@@ -1185,7 +1174,7 @@ namespace Emby.Server.Implementations.Dto
 
                 if (options.ContainsField(ItemFields.SeriesStudio))
                 {
-                    series = series ?? season.Series;
+                    series ??= season.Series;
                     if (series != null)
                     {
                         dto.SeriesStudio = series.Studios.FirstOrDefault();
@@ -1196,7 +1185,7 @@ namespace Emby.Server.Implementations.Dto
                 // TODO maybe remove the if statement entirely
                 // if (options.ContainsField(ItemFields.SeriesPrimaryImage))
                 {
-                    series = series ?? season.Series;
+                    series ??= season.Series;
                     if (series != null)
                     {
                         dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary);
@@ -1283,7 +1272,7 @@ namespace Emby.Server.Implementations.Dto
 
             var parent = currentItem.DisplayParent ?? currentItem.GetOwner() ?? currentItem.GetParent();
 
-            if (parent == null && !(originalItem is UserRootFolder) && !(originalItem is UserView) && !(originalItem is AggregateFolder) && !(originalItem is ICollectionFolder) && !(originalItem is Channel))
+            if (parent == null && originalItem is not UserRootFolder && originalItem is not UserView && originalItem is not AggregateFolder && originalItem is not ICollectionFolder && originalItem is not Channel)
             {
                 parent = _libraryManager.GetCollectionFolders(originalItem).FirstOrDefault();
             }
@@ -1316,9 +1305,12 @@ namespace Emby.Server.Implementations.Dto
 
             var imageTags = dto.ImageTags;
 
-            while (((!(imageTags != null && imageTags.ContainsKey(ImageType.Logo)) && logoLimit > 0) || (!(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && artLimit > 0) || (!(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) || parent is Series) &&
-                (parent = parent ?? (isFirst ? GetImageDisplayParent(item, item) ?? owner : parent)) != null)
+            while ((!(imageTags != null && imageTags.ContainsKey(ImageType.Logo)) && logoLimit > 0)
+                || (!(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && artLimit > 0)
+                || (!(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0)
+                || parent is Series)
             {
+                parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent;
                 if (parent == null)
                 {
                     break;
@@ -1348,7 +1340,7 @@ namespace Emby.Server.Implementations.Dto
                     }
                 }
 
-                if (thumbLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && (dto.ParentThumbItemId == null || parent is Series) && !(parent is ICollectionFolder) && !(parent is UserView))
+                if (thumbLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && (dto.ParentThumbItemId == null || parent is Series) && parent is not ICollectionFolder && parent is not UserView)
                 {
                     var image = allImages.FirstOrDefault(i => i.Type == ImageType.Thumb);
 
@@ -1398,7 +1390,6 @@ namespace Emby.Server.Implementations.Dto
         /// </summary>
         /// <param name="dto">The dto.</param>
         /// <param name="item">The item.</param>
-        /// <returns>Task.</returns>
         public void AttachPrimaryImageAspectRatio(IItemDto dto, BaseItem item)
         {
             dto.PrimaryImageAspectRatio = GetPrimaryImageAspectRatio(item);
@@ -1413,44 +1404,27 @@ namespace Emby.Server.Implementations.Dto
                 return null;
             }
 
-            ImageDimensions size;
-
-            var defaultAspectRatio = item.GetDefaultPrimaryImageAspectRatio();
-
-            if (defaultAspectRatio > 0)
-            {
-                return defaultAspectRatio;
-            }
-
             if (!imageInfo.IsLocalFile)
             {
-                return null;
+                return item.GetDefaultPrimaryImageAspectRatio();
             }
 
             try
             {
-                size = _imageProcessor.GetImageDimensions(item, imageInfo);
-
-                if (size.Width <= 0 || size.Height <= 0)
+                var size = _imageProcessor.GetImageDimensions(item, imageInfo);
+                var width = size.Width;
+                var height = size.Height;
+                if (width > 0 && height > 0)
                 {
-                    return null;
+                    return (double)width / height;
                 }
             }
             catch (Exception ex)
             {
-                _logger.LogError(ex, "Failed to determine primary image aspect ratio for {0}", imageInfo.Path);
-                return null;
-            }
-
-            var width = size.Width;
-            var height = size.Height;
-
-            if (width <= 0 || height <= 0)
-            {
-                return null;
+                _logger.LogError(ex, "Failed to determine primary image aspect ratio for {ImagePath}", imageInfo.Path);
             }
 
-            return (double)width / height;
+            return item.GetDefaultPrimaryImageAspectRatio();
         }
     }
 }

+ 11 - 14
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -23,17 +23,18 @@
   </ItemGroup>
 
   <ItemGroup>
+    <PackageReference Include="DiscUtils.Udf" Version="0.16.13" />
     <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" />
-    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
-    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="5.0.0" />
-    <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
-    <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" />
-    <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.7" />
-    <PackageReference Include="Mono.Nat" Version="3.0.1" />
-    <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.1.0" />
-    <PackageReference Include="sharpcompress" Version="0.28.3" />
+    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" />
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.1" />
+    <PackageReference Include="Mono.Nat" Version="3.0.2" />
+    <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.2.2" />
+    <PackageReference Include="sharpcompress" Version="0.30.1" />
     <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" />
-    <PackageReference Include="DotNet.Glob" Version="3.1.2" />
+    <PackageReference Include="DotNet.Glob" Version="3.1.3" />
   </ItemGroup>
 
   <ItemGroup>
@@ -41,15 +42,11 @@
   </ItemGroup>
 
   <PropertyGroup>
-    <TargetFramework>net5.0</TargetFramework>
+    <TargetFramework>net6.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors>
-    <Nullable>enable</Nullable>
     <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 -->
     <NoWarn>AD0001</NoWarn>
-    <AnalysisMode Condition=" '$(Configuration)' == 'Debug' ">AllEnabledByDefault</AnalysisMode>
-    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
   </PropertyGroup>
 
   <!-- Code Analyzers-->

+ 1 - 7
Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs

@@ -9,12 +9,10 @@ using System.Net;
 using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
-using Jellyfin.Data.Events;
 using Jellyfin.Networking.Configuration;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Plugins;
-using MediaBrowser.Model.Dlna;
 using Microsoft.Extensions.Logging;
 using Mono.Nat;
 
@@ -28,7 +26,6 @@ namespace Emby.Server.Implementations.EntryPoints
         private readonly IServerApplicationHost _appHost;
         private readonly ILogger<ExternalPortForwarding> _logger;
         private readonly IServerConfigurationManager _config;
-        private readonly IDeviceDiscovery _deviceDiscovery;
 
         private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new ConcurrentDictionary<IPEndPoint, byte>();
 
@@ -43,17 +40,14 @@ namespace Emby.Server.Implementations.EntryPoints
         /// <param name="logger">The logger.</param>
         /// <param name="appHost">The application host.</param>
         /// <param name="config">The configuration manager.</param>
-        /// <param name="deviceDiscovery">The device discovery.</param>
         public ExternalPortForwarding(
             ILogger<ExternalPortForwarding> logger,
             IServerApplicationHost appHost,
-            IServerConfigurationManager config,
-            IDeviceDiscovery deviceDiscovery)
+            IServerConfigurationManager config)
         {
             _logger = logger;
             _appHost = appHost;
             _config = config;
-            _deviceDiscovery = deviceDiscovery;
         }
 
         private string GetConfigIdentifier()

+ 3 - 3
Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs

@@ -149,7 +149,7 @@ namespace Emby.Server.Implementations.EntryPoints
 
         private static bool EnableRefreshMessage(BaseItem item)
         {
-            if (!(item is Folder folder))
+            if (item is not Folder folder)
             {
                 return false;
             }
@@ -403,7 +403,7 @@ namespace Emby.Server.Implementations.EntryPoints
                 return false;
             }
 
-            if (item is IItemByName && !(item is MusicArtist))
+            if (item is IItemByName && item is not MusicArtist)
             {
                 return false;
             }
@@ -436,7 +436,7 @@ namespace Emby.Server.Implementations.EntryPoints
         /// <summary>
         /// Translates the physical item to user library.
         /// </summary>
-        /// <typeparam name="T"></typeparam>
+        /// <typeparam name="T">The type of item.</typeparam>
         /// <param name="item">The item.</param>
         /// <param name="user">The user.</param>
         /// <param name="includeIfNotFound">if set to <c>true</c> [include if not found].</param>

+ 3 - 0
Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs

@@ -37,6 +37,9 @@ namespace Emby.Server.Implementations.EntryPoints
         /// <summary>
         /// Initializes a new instance of the <see cref="UdpServerEntryPoint" /> class.
         /// </summary>
+        /// <param name="logger">Instance of the <see cref="ILogger{UdpServerEntryPoint}"/> interface.</param>
+        /// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param>
+        /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
         public UdpServerEntryPoint(
             ILogger<UdpServerEntryPoint> logger,
             IServerApplicationHost appHost,

+ 4 - 4
Emby.Server.Implementations/HttpServer/Security/AuthService.cs

@@ -1,7 +1,7 @@
 #pragma warning disable CS1591
 
+using System.Threading.Tasks;
 using Jellyfin.Data.Enums;
-using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Http;
 
@@ -17,13 +17,13 @@ namespace Emby.Server.Implementations.HttpServer.Security
             _authorizationContext = authorizationContext;
         }
 
-        public AuthorizationInfo Authenticate(HttpRequest request)
+        public async Task<AuthorizationInfo> Authenticate(HttpRequest request)
         {
-            var auth = _authorizationContext.GetAuthorizationInfo(request);
+            var auth = await _authorizationContext.GetAuthorizationInfo(request).ConfigureAwait(false);
 
             if (!auth.HasToken)
             {
-                throw new AuthenticationException("Request does not contain a token.");
+                return auth;
             }
 
             if (!auth.IsAuthenticated)

+ 14 - 7
Emby.Server.Implementations/HttpServer/Security/SessionContext.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Library;
@@ -23,27 +24,33 @@ namespace Emby.Server.Implementations.HttpServer.Security
             _sessionManager = sessionManager;
         }
 
-        public SessionInfo GetSession(HttpContext requestContext)
+        public async Task<SessionInfo> GetSession(HttpContext requestContext)
         {
-            var authorization = _authContext.GetAuthorizationInfo(requestContext);
+            var authorization = await _authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false);
 
             var user = authorization.User;
-            return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.GetNormalizedRemoteIp().ToString(), user);
+            return await _sessionManager.LogSessionActivity(
+                authorization.Client,
+                authorization.Version,
+                authorization.DeviceId,
+                authorization.Device,
+                requestContext.GetNormalizedRemoteIp().ToString(),
+                user).ConfigureAwait(false);
         }
 
-        public SessionInfo GetSession(object requestContext)
+        public Task<SessionInfo> GetSession(object requestContext)
         {
             return GetSession((HttpContext)requestContext);
         }
 
-        public User? GetUser(HttpContext requestContext)
+        public async Task<User?> GetUser(HttpContext requestContext)
         {
-            var session = GetSession(requestContext);
+            var session = await GetSession(requestContext).ConfigureAwait(false);
 
             return session == null || session.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(session.UserId);
         }
 
-        public User? GetUser(object requestContext)
+        public Task<User?> GetUser(object requestContext)
         {
             return GetUser(((HttpRequest)requestContext).HttpContext);
         }

+ 6 - 6
Emby.Server.Implementations/HttpServer/WebSocketConnection.cs

@@ -7,7 +7,7 @@ using System.Text;
 using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
-using MediaBrowser.Common.Json;
+using Jellyfin.Extensions.Json;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Session;
@@ -62,7 +62,7 @@ namespace Emby.Server.Implementations.HttpServer
         public event EventHandler<EventArgs>? Closed;
 
         /// <summary>
-        /// Gets or sets the remote end point.
+        /// Gets the remote end point.
         /// </summary>
         public IPAddress? RemoteEndPoint { get; }
 
@@ -82,7 +82,7 @@ namespace Emby.Server.Implementations.HttpServer
         public DateTime LastKeepAliveDate { get; set; }
 
         /// <summary>
-        /// Gets or sets the query string.
+        /// Gets the query string.
         /// </summary>
         /// <value>The query string.</value>
         public IQueryCollection QueryString { get; }
@@ -96,7 +96,7 @@ namespace Emby.Server.Implementations.HttpServer
         /// <summary>
         /// Sends a message asynchronously.
         /// </summary>
-        /// <typeparam name="T"></typeparam>
+        /// <typeparam name="T">The type of the message.</typeparam>
         /// <param name="message">The message.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>Task.</returns>
@@ -150,8 +150,8 @@ namespace Emby.Server.Implementations.HttpServer
                 {
                     await ProcessInternal(pipe.Reader).ConfigureAwait(false);
                 }
-            } while (
-                (_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting)
+            }
+            while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting)
                 && receiveresult.MessageType != WebSocketMessageType.Close);
 
             Closed?.Invoke(this, EventArgs.Empty);

+ 6 - 1
Emby.Server.Implementations/HttpServer/WebSocketManager.cs

@@ -35,7 +35,12 @@ namespace Emby.Server.Implementations.HttpServer
         /// <inheritdoc />
         public async Task WebSocketRequestHandler(HttpContext context)
         {
-            _ = _authService.Authenticate(context.Request);
+            var authorizationInfo = await _authService.Authenticate(context.Request).ConfigureAwait(false);
+            if (!authorizationInfo.IsAuthenticated)
+            {
+                throw new SecurityException("Token is required");
+            }
+
             try
             {
                 _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);

+ 11 - 13
Emby.Server.Implementations/IO/FileRefresher.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -14,7 +12,7 @@ using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.IO
 {
-    public class FileRefresher : IDisposable
+    public sealed class FileRefresher : IDisposable
     {
         private readonly ILogger _logger;
         private readonly ILibraryManager _libraryManager;
@@ -22,7 +20,7 @@ namespace Emby.Server.Implementations.IO
 
         private readonly List<string> _affectedPaths = new List<string>();
         private readonly object _timerLock = new object();
-        private Timer _timer;
+        private Timer? _timer;
         private bool _disposed;
 
         public FileRefresher(string path, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger logger)
@@ -36,7 +34,7 @@ namespace Emby.Server.Implementations.IO
             AddPath(path);
         }
 
-        public event EventHandler<EventArgs> Completed;
+        public event EventHandler<EventArgs>? Completed;
 
         public string Path { get; private set; }
 
@@ -111,7 +109,7 @@ namespace Emby.Server.Implementations.IO
             RestartTimer();
         }
 
-        private void OnTimerCallback(object state)
+        private void OnTimerCallback(object? state)
         {
             List<string> paths;
 
@@ -127,7 +125,7 @@ namespace Emby.Server.Implementations.IO
 
             try
             {
-                ProcessPathChanges(paths.ToList());
+                ProcessPathChanges(paths);
             }
             catch (Exception ex)
             {
@@ -137,12 +135,12 @@ namespace Emby.Server.Implementations.IO
 
         private void ProcessPathChanges(List<string> paths)
         {
-            var itemsToRefresh = paths
+            IEnumerable<BaseItem> itemsToRefresh = paths
                 .Distinct(StringComparer.OrdinalIgnoreCase)
                 .Select(GetAffectedBaseItem)
                 .Where(item => item != null)
-                .GroupBy(x => x.Id)
-                .Select(x => x.First());
+                .GroupBy(x => x!.Id) // Removed null values in the previous .Where()
+                .Select(x => x.First())!;
 
             foreach (var item in itemsToRefresh)
             {
@@ -176,15 +174,15 @@ namespace Emby.Server.Implementations.IO
         /// </summary>
         /// <param name="path">The path.</param>
         /// <returns>BaseItem.</returns>
-        private BaseItem GetAffectedBaseItem(string path)
+        private BaseItem? GetAffectedBaseItem(string path)
         {
-            BaseItem item = null;
+            BaseItem? item = null;
 
             while (item == null && !string.IsNullOrEmpty(path))
             {
                 item = _libraryManager.FindByPath(path, null);
 
-                path = System.IO.Path.GetDirectoryName(path);
+                path = System.IO.Path.GetDirectoryName(path) ?? string.Empty;
             }
 
             if (item != null)

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels