Merge remote-tracking branch 'origin/master' into HEAD

pull/5894/head
Brian J. Murrell 3 years ago
commit 757970bfc1

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

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

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

@ -181,7 +181,7 @@ jobs:
inputs: inputs:
sshEndpoint: repository sshEndpoint: repository
runOptions: 'commands' runOptions: 'commands'
commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) & commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) $(Build.SourceBranch) &
- job: PublishNuget - job: PublishNuget
displayName: 'Publish NuGet packages' displayName: 'Publish NuGet packages'
@ -195,10 +195,10 @@ jobs:
steps: steps:
- task: UseDotNet@2 - task: UseDotNet@2
displayName: 'Use .NET 5.0 sdk' displayName: 'Use .NET 6.0 sdk'
inputs: inputs:
packageType: 'sdk' packageType: 'sdk'
version: '5.0.x' version: '6.0.x'
- task: DotNetCoreCLI@2 - task: DotNetCoreCLI@2
displayName: 'Build Stable Nuget packages' displayName: 'Build Stable Nuget packages'
@ -211,6 +211,7 @@ jobs:
MediaBrowser.Controller/MediaBrowser.Controller.csproj MediaBrowser.Controller/MediaBrowser.Controller.csproj
MediaBrowser.Model/MediaBrowser.Model.csproj MediaBrowser.Model/MediaBrowser.Model.csproj
Emby.Naming/Emby.Naming.csproj Emby.Naming/Emby.Naming.csproj
src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
custom: 'pack' custom: 'pack'
arguments: -o $(Build.ArtifactStagingDirectory) -p:Version=$(JellyfinVersion) arguments: -o $(Build.ArtifactStagingDirectory) -p:Version=$(JellyfinVersion)
@ -225,6 +226,7 @@ jobs:
MediaBrowser.Controller/MediaBrowser.Controller.csproj MediaBrowser.Controller/MediaBrowser.Controller.csproj
MediaBrowser.Model/MediaBrowser.Model.csproj MediaBrowser.Model/MediaBrowser.Model.csproj
Emby.Naming/Emby.Naming.csproj Emby.Naming/Emby.Naming.csproj
src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
custom: 'pack' custom: 'pack'
arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory) -p:Stability=Unstable' arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory) -p:Stability=Unstable'

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

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

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

@ -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

6
.github/stale.yml vendored

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

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

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

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

@ -1,17 +0,0 @@
name: 'Merge Conflicts'
on:
push:
branches:
- master
pull_request_target:
types:
- synchronize
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: eps1lon/actions-label-merge-conflict@v2.0.1
with:
dirtyLabel: 'merge conflict'
repoToken: ${{ secrets.GH_TOKEN }}

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

@ -1,27 +0,0 @@
name: Automatic Rebase
on:
issue_comment:
jobs:
rebase:
name: Rebase
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
runs-on: ubuntu-latest
steps:
- name: Notify as seen
uses: peter-evans/create-or-update-comment@v1.4.5
with:
token: ${{ secrets.GH_TOKEN }}
comment-id: ${{ github.event.comment.id }}
reactions: '+1'
- name: Checkout the latest code
uses: actions/checkout@v2
with:
token: ${{ secrets.GH_TOKEN }}
fetch-depth: 0
- name: Automatic Rebase
uses: cirrus-actions/rebase@1.4
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}

4
.gitignore vendored

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

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

@ -46,6 +46,7 @@
- [fruhnow](https://github.com/fruhnow) - [fruhnow](https://github.com/fruhnow)
- [geilername](https://github.com/geilername) - [geilername](https://github.com/geilername)
- [gnattu](https://github.com/gnattu) - [gnattu](https://github.com/gnattu)
- [GodTamIt](https://github.com/GodTamIt)
- [grafixeyehero](https://github.com/grafixeyehero) - [grafixeyehero](https://github.com/grafixeyehero)
- [h1nk](https://github.com/h1nk) - [h1nk](https://github.com/h1nk)
- [hawken93](https://github.com/hawken93) - [hawken93](https://github.com/hawken93)
@ -70,6 +71,7 @@
- [marius-luca-87](https://github.com/marius-luca-87) - [marius-luca-87](https://github.com/marius-luca-87)
- [mark-monteiro](https://github.com/mark-monteiro) - [mark-monteiro](https://github.com/mark-monteiro)
- [Matt07211](https://github.com/Matt07211) - [Matt07211](https://github.com/Matt07211)
- [Maxr1998](https://github.com/Maxr1998)
- [mcarlton00](https://github.com/mcarlton00) - [mcarlton00](https://github.com/mcarlton00)
- [mitchfizz05](https://github.com/mitchfizz05) - [mitchfizz05](https://github.com/mitchfizz05)
- [MrTimscampi](https://github.com/MrTimscampi) - [MrTimscampi](https://github.com/MrTimscampi)
@ -110,7 +112,7 @@
- [sorinyo2004](https://github.com/sorinyo2004) - [sorinyo2004](https://github.com/sorinyo2004)
- [sparky8251](https://github.com/sparky8251) - [sparky8251](https://github.com/sparky8251)
- [spookbits](https://github.com/spookbits) - [spookbits](https://github.com/spookbits)
- [ssenart] (https://github.com/ssenart) - [ssenart](https://github.com/ssenart)
- [stanionascu](https://github.com/stanionascu) - [stanionascu](https://github.com/stanionascu)
- [stevehayles](https://github.com/stevehayles) - [stevehayles](https://github.com/stevehayles)
- [SuperSandro2000](https://github.com/SuperSandro2000) - [SuperSandro2000](https://github.com/SuperSandro2000)
@ -146,6 +148,8 @@
- [nielsvanvelzen](https://github.com/nielsvanvelzen) - [nielsvanvelzen](https://github.com/nielsvanvelzen)
- [skyfrk](https://github.com/skyfrk) - [skyfrk](https://github.com/skyfrk)
- [ianjazz246](https://github.com/ianjazz246) - [ianjazz246](https://github.com/ianjazz246)
- [peterspenler](https://github.com/peterspenler)
- [MBR-0001](https://github.com/MBR-0001)
# Emby Contributors # Emby Contributors
@ -210,3 +214,5 @@
- [Tim Hobbs](https://github.com/timhobbs) - [Tim Hobbs](https://github.com/timhobbs)
- [SvenVandenbrande](https://github.com/SvenVandenbrande) - [SvenVandenbrande](https://github.com/SvenVandenbrande)
- [olsh](https://github.com/olsh) - [olsh](https://github.com/olsh)
- [lbenini](https://github.com/lbenini)
- [gnuyent](https://github.com/gnuyent)

@ -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>

@ -1,22 +1,18 @@
ARG DOTNET_VERSION=5.0 # DESIGNED FOR BUILDING ON AMD64 ONLY
#####################################
# Requires binfm_misc registration
# https://github.com/multiarch/qemu-user-static#binfmt_misc-register
ARG DOTNET_VERSION=6.0
FROM node:alpine as web-builder FROM node:lts-alpine as web-builder
ARG JELLYFIN_WEB_VERSION=master ARG JELLYFIN_WEB_VERSION=master
RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python \ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \
&& curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
&& cd jellyfin-web-* \ && cd jellyfin-web-* \
&& npm ci --no-audit \ && npm ci --no-audit --unsafe-perm \
&& mv dist /dist && mv dist /dist
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder FROM debian:stable-slim as app
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
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND="noninteractive" ARG DEBIAN_FRONTEND="noninteractive"
@ -25,19 +21,17 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
# https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support) # https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support)
ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
COPY --from=builder /jellyfin /jellyfin
COPY --from=web-builder /dist /jellyfin/jellyfin-web
# https://github.com/intel/compute-runtime/releases # https://github.com/intel/compute-runtime/releases
ARG GMMLIB_VERSION=20.3.2 ARG GMMLIB_VERSION=21.2.1
ARG IGC_VERSION=1.0.5435 ARG IGC_VERSION=1.0.8517
ARG NEO_VERSION=20.46.18421 ARG NEO_VERSION=21.35.20826
ARG LEVEL_ZERO_VERSION=1.0.18421 ARG LEVEL_ZERO_VERSION=1.2.20826
# Install dependencies: # Install dependencies:
# mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding. # mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding.
# curl: healthcheck
RUN apt-get update \ RUN apt-get update \
&& apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https \ && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https curl \
&& wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \ && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \
&& echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \ && echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \
&& apt-get update \ && apt-get update \
@ -68,14 +62,32 @@ RUN apt-get update \
&& chmod 777 /cache /config /media \ && chmod 777 /cache /config /media \
&& sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 # ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
ENV LC_ALL en_US.UTF-8 ENV LC_ALL en_US.UTF-8
ENV LANG en_US.UTF-8 ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en ENV LANGUAGE en_US:en
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
WORKDIR /repo
COPY . .
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
# because of changes in docker and systemd we need to not build in parallel at the moment
# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting
RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 "-p:DebugSymbols=false;DebugType=none"
FROM app
ENV HEALTHCHECK_URL=http://localhost:8096/health
COPY --from=builder /jellyfin /jellyfin
COPY --from=web-builder /dist /jellyfin/jellyfin-web
EXPOSE 8096 EXPOSE 8096
VOLUME /cache /config /media VOLUME /cache /config /media
ENTRYPOINT ["./jellyfin/jellyfin", \ ENTRYPOINT ["./jellyfin/jellyfin", \
"--datadir", "/config", \ "--datadir", "/config", \
"--cachedir", "/cache", \ "--cachedir", "/cache", \
"--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"] "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"]
HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \
CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1

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

@ -1,30 +1,20 @@
# DESIGNED FOR BUILDING ON AMD64 ONLY # DESIGNED FOR BUILDING ON ARM64 ONLY
##################################### #####################################
# Requires binfm_misc registration # Requires binfm_misc registration
# https://github.com/multiarch/qemu-user-static#binfmt_misc-register # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
ARG DOTNET_VERSION=5.0 ARG DOTNET_VERSION=6.0
FROM node:alpine as web-builder FROM node:lts-alpine as web-builder
ARG JELLYFIN_WEB_VERSION=master ARG JELLYFIN_WEB_VERSION=master
RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python \ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \
&& curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
&& cd jellyfin-web-* \ && cd jellyfin-web-* \
&& npm ci --no-audit \ && npm ci --no-audit --unsafe-perm \
&& mv dist /dist && mv dist /dist
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
WORKDIR /repo
COPY . .
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
# Discard objs - may cause failures if exists
RUN find . -type d -name obj | xargs -r rm -r
# Build
RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 "-p:DebugSymbols=false;DebugType=none"
FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu
FROM arm64v8/debian:buster-slim FROM arm64v8/debian:stable-slim as app
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND="noninteractive" ARG DEBIAN_FRONTEND="noninteractive"
@ -34,6 +24,8 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
COPY --from=qemu /usr/bin/qemu-aarch64-static /usr/bin COPY --from=qemu /usr/bin/qemu-aarch64-static /usr/bin
# curl: healcheck
RUN apt-get update && apt-get install --no-install-recommends --no-install-suggests -y \ RUN apt-get update && apt-get install --no-install-recommends --no-install-suggests -y \
ffmpeg \ ffmpeg \
libssl-dev \ libssl-dev \
@ -43,6 +35,7 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge
libomxil-bellagio0 \ libomxil-bellagio0 \
libomxil-bellagio-bin \ libomxil-bellagio-bin \
locales \ locales \
curl \
&& apt-get clean autoclean -y \ && apt-get clean autoclean -y \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
@ -50,17 +43,33 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge
&& chmod 777 /cache /config /media \ && chmod 777 /cache /config /media \
&& sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen
COPY --from=builder /jellyfin /jellyfin # ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
COPY --from=web-builder /dist /jellyfin/jellyfin-web
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
ENV LC_ALL en_US.UTF-8 ENV LC_ALL en_US.UTF-8
ENV LANG en_US.UTF-8 ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en ENV LANGUAGE en_US:en
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
WORKDIR /repo
COPY . .
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
# Discard objs - may cause failures if exists
RUN find . -type d -name obj | xargs -r rm -r
# Build
RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 "-p:DebugSymbols=false;DebugType=none"
FROM app
ENV HEALTHCHECK_URL=http://localhost:8096/health
COPY --from=builder /jellyfin /jellyfin
COPY --from=web-builder /dist /jellyfin/jellyfin-web
EXPOSE 8096 EXPOSE 8096
VOLUME /cache /config /media VOLUME /cache /config /media
ENTRYPOINT ["./jellyfin/jellyfin", \ ENTRYPOINT ["./jellyfin/jellyfin", \
"--datadir", "/config", \ "--datadir", "/config", \
"--cachedir", "/cache", \ "--cachedir", "/cache", \
"--ffmpeg", "/usr/bin/ffmpeg"] "--ffmpeg", "/usr/bin/ffmpeg"]
HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \
CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

@ -1,3 +1,5 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@ -39,8 +41,6 @@ namespace Emby.Dlna.Didl
private const string NsUpnp = "urn:schemas-upnp-org:metadata-1-0/upnp/"; private const string NsUpnp = "urn:schemas-upnp-org:metadata-1-0/upnp/";
private const string NsDlna = "urn:schemas-dlna-org:metadata-1-0/"; private const string NsDlna = "urn:schemas-dlna-org:metadata-1-0/";
private readonly CultureInfo _usCulture = new CultureInfo("en-US");
private readonly DeviceProfile _profile; private readonly DeviceProfile _profile;
private readonly IImageProcessor _imageProcessor; private readonly IImageProcessor _imageProcessor;
private readonly string _serverAddress; private readonly string _serverAddress;
@ -208,7 +208,8 @@ namespace Emby.Dlna.Didl
var targetWidth = streamInfo.TargetWidth; var targetWidth = streamInfo.TargetWidth;
var targetHeight = streamInfo.TargetHeight; var targetHeight = streamInfo.TargetHeight;
var contentFeatureList = new ContentFeatureBuilder(_profile).BuildVideoHeader( var contentFeatureList = ContentFeatureBuilder.BuildVideoHeader(
_profile,
streamInfo.Container, streamInfo.Container,
streamInfo.TargetVideoCodec.FirstOrDefault(), streamInfo.TargetVideoCodec.FirstOrDefault(),
streamInfo.TargetAudioCodec.FirstOrDefault(), streamInfo.TargetAudioCodec.FirstOrDefault(),
@ -314,7 +315,7 @@ namespace Emby.Dlna.Didl
if (mediaSource.RunTimeTicks.HasValue) if (mediaSource.RunTimeTicks.HasValue)
{ {
writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", _usCulture)); writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture));
} }
if (filter.Contains("res@size")) if (filter.Contains("res@size"))
@ -325,7 +326,7 @@ namespace Emby.Dlna.Didl
if (size.HasValue) if (size.HasValue)
{ {
writer.WriteAttributeString("size", size.Value.ToString(_usCulture)); writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture));
} }
} }
} }
@ -339,7 +340,7 @@ namespace Emby.Dlna.Didl
if (targetChannels.HasValue) if (targetChannels.HasValue)
{ {
writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(_usCulture)); writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture));
} }
if (filter.Contains("res@resolution")) if (filter.Contains("res@resolution"))
@ -358,12 +359,12 @@ namespace Emby.Dlna.Didl
if (targetSampleRate.HasValue) if (targetSampleRate.HasValue)
{ {
writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(_usCulture)); writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture));
} }
if (totalBitrate.HasValue) if (totalBitrate.HasValue)
{ {
writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(_usCulture)); writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(CultureInfo.InvariantCulture));
} }
var mediaProfile = _profile.GetVideoMediaProfile( var mediaProfile = _profile.GetVideoMediaProfile(
@ -549,7 +550,7 @@ namespace Emby.Dlna.Didl
if (mediaSource.RunTimeTicks.HasValue) if (mediaSource.RunTimeTicks.HasValue)
{ {
writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", _usCulture)); writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture));
} }
if (filter.Contains("res@size")) if (filter.Contains("res@size"))
@ -560,7 +561,7 @@ namespace Emby.Dlna.Didl
if (size.HasValue) if (size.HasValue)
{ {
writer.WriteAttributeString("size", size.Value.ToString(_usCulture)); writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture));
} }
} }
} }
@ -572,17 +573,17 @@ namespace Emby.Dlna.Didl
if (targetChannels.HasValue) if (targetChannels.HasValue)
{ {
writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(_usCulture)); writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture));
} }
if (targetSampleRate.HasValue) if (targetSampleRate.HasValue)
{ {
writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(_usCulture)); writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture));
} }
if (targetAudioBitrate.HasValue) if (targetAudioBitrate.HasValue)
{ {
writer.WriteAttributeString("bitrate", targetAudioBitrate.Value.ToString(_usCulture)); writer.WriteAttributeString("bitrate", targetAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
} }
var mediaProfile = _profile.GetAudioMediaProfile( var mediaProfile = _profile.GetAudioMediaProfile(
@ -599,7 +600,8 @@ namespace Emby.Dlna.Didl
? MimeTypes.GetMimeType(filename) ? MimeTypes.GetMimeType(filename)
: mediaProfile.MimeType; : mediaProfile.MimeType;
var contentFeatures = new ContentFeatureBuilder(_profile).BuildAudioHeader( var contentFeatures = ContentFeatureBuilder.BuildAudioHeader(
_profile,
streamInfo.Container, streamInfo.Container,
streamInfo.TargetAudioCodec.FirstOrDefault(), streamInfo.TargetAudioCodec.FirstOrDefault(),
targetAudioBitrate, targetAudioBitrate,
@ -635,7 +637,7 @@ namespace Emby.Dlna.Didl
writer.WriteAttributeString("restricted", "1"); writer.WriteAttributeString("restricted", "1");
writer.WriteAttributeString("searchable", "1"); writer.WriteAttributeString("searchable", "1");
writer.WriteAttributeString("childCount", childCount.ToString(_usCulture)); writer.WriteAttributeString("childCount", childCount.ToString(CultureInfo.InvariantCulture));
var clientId = GetClientId(folder, stubType); var clientId = GetClientId(folder, stubType);
@ -727,7 +729,7 @@ namespace Emby.Dlna.Didl
{ {
if (item.PremiereDate.HasValue) if (item.PremiereDate.HasValue)
{ {
AddValue(writer, "dc", "date", item.PremiereDate.Value.ToString("o", CultureInfo.InvariantCulture), NsDc); AddValue(writer, "dc", "date", item.PremiereDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), NsDc);
} }
} }
@ -744,7 +746,7 @@ namespace Emby.Dlna.Didl
AddValue(writer, "upnp", "publisher", studio, NsUpnp); AddValue(writer, "upnp", "publisher", studio, NsUpnp);
} }
if (!(item is Folder)) if (item is not Folder)
{ {
if (filter.Contains("dc:description")) if (filter.Contains("dc:description"))
{ {
@ -927,11 +929,11 @@ namespace Emby.Dlna.Didl
if (item.IndexNumber.HasValue) if (item.IndexNumber.HasValue)
{ {
AddValue(writer, "upnp", "originalTrackNumber", item.IndexNumber.Value.ToString(_usCulture), NsUpnp); AddValue(writer, "upnp", "originalTrackNumber", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), NsUpnp);
if (item is Episode) if (item is Episode)
{ {
AddValue(writer, "upnp", "episodeNumber", item.IndexNumber.Value.ToString(_usCulture), NsUpnp); AddValue(writer, "upnp", "episodeNumber", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), NsUpnp);
} }
} }
} }
@ -974,15 +976,28 @@ namespace Emby.Dlna.Didl
return; return;
} }
var albumartUrlInfo = GetImageUrl(imageInfo, _profile.MaxAlbumArtWidth, _profile.MaxAlbumArtHeight, "jpg"); // TODO: Remove these default values
var albumArtUrlInfo = GetImageUrl(
imageInfo,
_profile.MaxAlbumArtWidth ?? 10000,
_profile.MaxAlbumArtHeight ?? 10000,
"jpg");
writer.WriteStartElement("upnp", "albumArtURI", NsUpnp); writer.WriteStartElement("upnp", "albumArtURI", NsUpnp);
writer.WriteAttributeString("dlna", "profileID", NsDlna, _profile.AlbumArtPn); if (!string.IsNullOrEmpty(_profile.AlbumArtPn))
writer.WriteString(albumartUrlInfo.url); {
writer.WriteAttributeString("dlna", "profileID", NsDlna, _profile.AlbumArtPn);
}
writer.WriteString(albumArtUrlInfo.url);
writer.WriteFullEndElement(); writer.WriteFullEndElement();
// TOOD: Remove these default values // TODO: Remove these default values
var iconUrlInfo = GetImageUrl(imageInfo, _profile.MaxIconWidth ?? 48, _profile.MaxIconHeight ?? 48, "jpg"); var iconUrlInfo = GetImageUrl(
imageInfo,
_profile.MaxIconWidth ?? 48,
_profile.MaxIconHeight ?? 48,
"jpg");
writer.WriteElementString("upnp", "icon", NsUpnp, iconUrlInfo.url); writer.WriteElementString("upnp", "icon", NsUpnp, iconUrlInfo.url);
if (!_profile.EnableAlbumArtInDidl) if (!_profile.EnableAlbumArtInDidl)
@ -1033,8 +1048,7 @@ namespace Emby.Dlna.Didl
var width = albumartUrlInfo.width ?? maxWidth; var width = albumartUrlInfo.width ?? maxWidth;
var height = albumartUrlInfo.height ?? maxHeight; var height = albumartUrlInfo.height ?? maxHeight;
var contentFeatures = new ContentFeatureBuilder(_profile) var contentFeatures = ContentFeatureBuilder.BuildImageHeader(_profile, format, width, height, imageInfo.IsDirectStream, org_Pn);
.BuildImageHeader(format, width, height, imageInfo.IsDirectStream, org_Pn);
writer.WriteAttributeString( writer.WriteAttributeString(
"protocolInfo", "protocolInfo",
@ -1206,8 +1220,7 @@ namespace Emby.Dlna.Didl
if (width.HasValue && height.HasValue) if (width.HasValue && height.HasValue)
{ {
var newSize = DrawingUtils.Resize( var newSize = DrawingUtils.Resize(new ImageDimensions(width.Value, height.Value), 0, 0, maxWidth, maxHeight);
new ImageDimensions(width.Value, height.Value), 0, 0, maxWidth, maxHeight);
width = newSize.Width; width = newSize.Width;
height = newSize.Height; height = newSize.Height;

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

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

@ -1,5 +1,4 @@
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
@ -12,9 +11,9 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Emby.Dlna.Profiles; using Emby.Dlna.Profiles;
using Emby.Dlna.Server; using Emby.Dlna.Server;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Json;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Dlna;
using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Drawing;
@ -94,12 +93,14 @@ namespace Emby.Dlna
} }
} }
/// <inheritdoc />
public DeviceProfile GetDefaultProfile() public DeviceProfile GetDefaultProfile()
{ {
return new DefaultProfile(); return new DefaultProfile();
} }
public DeviceProfile GetProfile(DeviceIdentification deviceInfo) /// <inheritdoc />
public DeviceProfile? GetProfile(DeviceIdentification deviceInfo)
{ {
if (deviceInfo == null) if (deviceInfo == null)
{ {
@ -109,109 +110,57 @@ namespace Emby.Dlna
var profile = GetProfiles() var profile = GetProfiles()
.FirstOrDefault(i => i.Identification != null && IsMatch(deviceInfo, i.Identification)); .FirstOrDefault(i => i.Identification != null && IsMatch(deviceInfo, i.Identification));
if (profile != null) if (profile == null)
{ {
_logger.LogDebug("Found matching device profile: {0}", profile.Name); _logger.LogInformation("No matching device profile found. The default will need to be used. \n{@Profile}", deviceInfo);
} }
else else
{ {
LogUnmatchedProfile(deviceInfo); _logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name);
} }
return profile; return profile;
} }
private void LogUnmatchedProfile(DeviceIdentification profile) /// <summary>
/// Attempts to match a device with a profile.
/// Rules:
/// - If the profile field has no value, the field matches irregardless of its contents.
/// - the profile field can be an exact match, or a reg exp.
/// </summary>
/// <param name="deviceInfo">The <see cref="DeviceIdentification"/> of the device.</param>
/// <param name="profileInfo">The <see cref="DeviceIdentification"/> of the profile.</param>
/// <returns><b>True</b> if they match.</returns>
public bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo)
{ {
var builder = new StringBuilder(); return IsRegexOrSubstringMatch(deviceInfo.FriendlyName, profileInfo.FriendlyName)
&& IsRegexOrSubstringMatch(deviceInfo.Manufacturer, profileInfo.Manufacturer)
builder.AppendLine("No matching device profile found. The default will need to be used."); && IsRegexOrSubstringMatch(deviceInfo.ManufacturerUrl, profileInfo.ManufacturerUrl)
builder.Append("FriendlyName: ").AppendLine(profile.FriendlyName); && IsRegexOrSubstringMatch(deviceInfo.ModelDescription, profileInfo.ModelDescription)
builder.Append("Manufacturer: ").AppendLine(profile.Manufacturer); && IsRegexOrSubstringMatch(deviceInfo.ModelName, profileInfo.ModelName)
builder.Append("ManufacturerUrl: ").AppendLine(profile.ManufacturerUrl); && IsRegexOrSubstringMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber)
builder.Append("ModelDescription: ").AppendLine(profile.ModelDescription); && IsRegexOrSubstringMatch(deviceInfo.ModelUrl, profileInfo.ModelUrl)
builder.Append("ModelName: ").AppendLine(profile.ModelName); && IsRegexOrSubstringMatch(deviceInfo.SerialNumber, profileInfo.SerialNumber);
builder.Append("ModelNumber: ").AppendLine(profile.ModelNumber);
builder.Append("ModelUrl: ").AppendLine(profile.ModelUrl);
builder.Append("SerialNumber: ").AppendLine(profile.SerialNumber);
_logger.LogInformation(builder.ToString());
} }
private bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo) private bool IsRegexOrSubstringMatch(string input, string pattern)
{ {
if (!string.IsNullOrEmpty(profileInfo.FriendlyName)) if (string.IsNullOrEmpty(pattern))
{
if (deviceInfo.FriendlyName == null || !IsRegexOrSubstringMatch(deviceInfo.FriendlyName, profileInfo.FriendlyName))
{
return false;
}
}
if (!string.IsNullOrEmpty(profileInfo.Manufacturer))
{
if (deviceInfo.Manufacturer == null || !IsRegexOrSubstringMatch(deviceInfo.Manufacturer, profileInfo.Manufacturer))
{
return false;
}
}
if (!string.IsNullOrEmpty(profileInfo.ManufacturerUrl))
{
if (deviceInfo.ManufacturerUrl == null || !IsRegexOrSubstringMatch(deviceInfo.ManufacturerUrl, profileInfo.ManufacturerUrl))
{
return false;
}
}
if (!string.IsNullOrEmpty(profileInfo.ModelDescription))
{
if (deviceInfo.ModelDescription == null || !IsRegexOrSubstringMatch(deviceInfo.ModelDescription, profileInfo.ModelDescription))
{
return false;
}
}
if (!string.IsNullOrEmpty(profileInfo.ModelName))
{
if (deviceInfo.ModelName == null || !IsRegexOrSubstringMatch(deviceInfo.ModelName, profileInfo.ModelName))
{
return false;
}
}
if (!string.IsNullOrEmpty(profileInfo.ModelNumber))
{
if (deviceInfo.ModelNumber == null || !IsRegexOrSubstringMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber))
{
return false;
}
}
if (!string.IsNullOrEmpty(profileInfo.ModelUrl))
{ {
if (deviceInfo.ModelUrl == null || !IsRegexOrSubstringMatch(deviceInfo.ModelUrl, profileInfo.ModelUrl)) // In profile identification: An empty pattern matches anything.
{ return true;
return false;
}
} }
if (!string.IsNullOrEmpty(profileInfo.SerialNumber)) if (string.IsNullOrEmpty(input))
{ {
if (deviceInfo.SerialNumber == null || !IsRegexOrSubstringMatch(deviceInfo.SerialNumber, profileInfo.SerialNumber)) // The profile contains a value, and the device doesn't.
{ return false;
return false;
}
} }
return true;
}
private bool IsRegexOrSubstringMatch(string input, string pattern)
{
try try
{ {
return input.Contains(pattern, StringComparison.OrdinalIgnoreCase) || Regex.IsMatch(input, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); return input.Equals(pattern, StringComparison.OrdinalIgnoreCase)
|| Regex.IsMatch(input, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
@ -220,7 +169,8 @@ namespace Emby.Dlna
} }
} }
public DeviceProfile GetProfile(IHeaderDictionary headers) /// <inheritdoc />
public DeviceProfile? GetProfile(IHeaderDictionary headers)
{ {
if (headers == null) if (headers == null)
{ {
@ -228,15 +178,13 @@ namespace Emby.Dlna
} }
var profile = GetProfiles().FirstOrDefault(i => i.Identification != null && IsMatch(headers, i.Identification)); var profile = GetProfiles().FirstOrDefault(i => i.Identification != null && IsMatch(headers, i.Identification));
if (profile == null)
if (profile != null)
{ {
_logger.LogDebug("Found matching device profile: {0}", profile.Name); _logger.LogDebug("No matching device profile found. {@Headers}", headers);
} }
else else
{ {
var headerString = string.Join(", ", headers.Select(i => string.Format(CultureInfo.InvariantCulture, "{0}={1}", i.Key, i.Value))); _logger.LogDebug("Found matching device profile: {0}", profile.Name);
_logger.LogDebug("No matching device profile found. {0}", headerString);
} }
return profile; return profile;
@ -286,19 +234,19 @@ namespace Emby.Dlna
return xmlFies return xmlFies
.Select(i => ParseProfileFile(i, type)) .Select(i => ParseProfileFile(i, type))
.Where(i => i != null) .Where(i => i != null)
.ToList(); .ToList()!; // We just filtered out all the nulls
} }
catch (IOException) catch (IOException)
{ {
return new List<DeviceProfile>(); return Array.Empty<DeviceProfile>();
} }
} }
private DeviceProfile ParseProfileFile(string path, DeviceProfileType type) private DeviceProfile? ParseProfileFile(string path, DeviceProfileType type)
{ {
lock (_profiles) lock (_profiles)
{ {
if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile> profileTuple)) if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile>? profileTuple))
{ {
return profileTuple.Item2; return profileTuple.Item2;
} }
@ -326,7 +274,8 @@ namespace Emby.Dlna
} }
} }
public DeviceProfile GetProfile(string id) /// <inheritdoc />
public DeviceProfile? GetProfile(string id)
{ {
if (string.IsNullOrEmpty(id)) if (string.IsNullOrEmpty(id))
{ {
@ -355,6 +304,7 @@ namespace Emby.Dlna
} }
} }
/// <inheritdoc />
public IEnumerable<DeviceProfileInfo> GetProfileInfos() public IEnumerable<DeviceProfileInfo> GetProfileInfos()
{ {
return GetProfileInfosInternal().Select(i => i.Info); return GetProfileInfosInternal().Select(i => i.Info);
@ -362,17 +312,14 @@ namespace Emby.Dlna
private InternalProfileInfo GetInternalProfileInfo(FileSystemMetadata file, DeviceProfileType type) private InternalProfileInfo GetInternalProfileInfo(FileSystemMetadata file, DeviceProfileType type)
{ {
return new InternalProfileInfo return new InternalProfileInfo(
{ new DeviceProfileInfo
Path = file.FullName,
Info = new DeviceProfileInfo
{ {
Id = file.FullName.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture), Id = file.FullName.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture),
Name = _fileSystem.GetFileNameWithoutExtension(file), Name = _fileSystem.GetFileNameWithoutExtension(file),
Type = type Type = type
} },
}; file.FullName);
} }
private async Task ExtractSystemProfilesAsync() private async Task ExtractSystemProfilesAsync()
@ -392,16 +339,20 @@ namespace Emby.Dlna
systemProfilesPath, systemProfilesPath,
Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length)); Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length));
using (var stream = _assembly.GetManifestResourceStream(name)) // The stream should exist as we just got its name from GetManifestResourceNames
using (var stream = _assembly.GetManifestResourceStream(name)!)
{ {
var length = stream.Length;
var fileInfo = _fileSystem.GetFileInfo(path); var fileInfo = _fileSystem.GetFileInfo(path);
if (!fileInfo.Exists || fileInfo.Length != stream.Length) if (!fileInfo.Exists || fileInfo.Length != length)
{ {
Directory.CreateDirectory(systemProfilesPath); Directory.CreateDirectory(systemProfilesPath);
// use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . var fileOptions = AsyncFile.WriteOptions;
using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) fileOptions.Mode = FileMode.Create;
fileOptions.PreallocationSize = length;
using (var fileStream = new FileStream(path, fileOptions))
{ {
await stream.CopyToAsync(fileStream).ConfigureAwait(false); await stream.CopyToAsync(fileStream).ConfigureAwait(false);
} }
@ -413,6 +364,7 @@ namespace Emby.Dlna
Directory.CreateDirectory(UserProfilesPath); Directory.CreateDirectory(UserProfilesPath);
} }
/// <inheritdoc />
public void DeleteProfile(string id) public void DeleteProfile(string id)
{ {
var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase)); var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase));
@ -430,6 +382,7 @@ namespace Emby.Dlna
} }
} }
/// <inheritdoc />
public void CreateProfile(DeviceProfile profile) public void CreateProfile(DeviceProfile profile)
{ {
profile = ReserializeProfile(profile); profile = ReserializeProfile(profile);
@ -445,7 +398,8 @@ namespace Emby.Dlna
SaveProfile(profile, path, DeviceProfileType.User); SaveProfile(profile, path, DeviceProfileType.User);
} }
public void UpdateProfile(DeviceProfile profile) /// <inheritdoc />
public void UpdateProfile(string profileId, DeviceProfile profile)
{ {
profile = ReserializeProfile(profile); profile = ReserializeProfile(profile);
@ -459,7 +413,7 @@ namespace Emby.Dlna
throw new ArgumentException("Profile is missing Name"); throw new ArgumentException("Profile is missing Name");
} }
var current = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, profile.Id, StringComparison.OrdinalIgnoreCase)); var current = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, profileId, StringComparison.OrdinalIgnoreCase));
var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml"; var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml";
var path = Path.Combine(UserProfilesPath, newFilename); var path = Path.Combine(UserProfilesPath, newFilename);
@ -503,9 +457,11 @@ namespace Emby.Dlna
var json = JsonSerializer.Serialize(profile, _jsonOptions); var json = JsonSerializer.Serialize(profile, _jsonOptions);
return JsonSerializer.Deserialize<DeviceProfile>(json, _jsonOptions); // Output can't be null if the input isn't null
return JsonSerializer.Deserialize<DeviceProfile>(json, _jsonOptions)!;
} }
/// <inheritdoc />
public string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress) public string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress)
{ {
var profile = GetDefaultProfile(); var profile = GetDefaultProfile();
@ -515,26 +471,37 @@ namespace Emby.Dlna
return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml(); return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml();
} }
public ImageStream GetIcon(string filename) /// <inheritdoc />
public ImageStream? GetIcon(string filename)
{ {
var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase) var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
? ImageFormat.Png ? ImageFormat.Png
: ImageFormat.Jpg; : ImageFormat.Jpg;
var resource = GetType().Namespace + ".Images." + filename.ToLowerInvariant(); var resource = GetType().Namespace + ".Images." + filename.ToLowerInvariant();
var stream = _assembly.GetManifestResourceStream(resource);
if (stream == null)
{
return null;
}
return new ImageStream return new ImageStream(stream)
{ {
Format = format, Format = format
Stream = _assembly.GetManifestResourceStream(resource)
}; };
} }
private class InternalProfileInfo private class InternalProfileInfo
{ {
internal DeviceProfileInfo Info { get; set; } internal InternalProfileInfo(DeviceProfileInfo info, string path)
{
Info = info;
Path = path;
}
internal DeviceProfileInfo Info { get; }
internal string Path { get; set; } internal string Path { get; }
} }
} }

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

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

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

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

@ -1,3 +1,5 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@ -25,11 +27,9 @@ using MediaBrowser.Controller.TV;
using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Net; using MediaBrowser.Model.Net;
using MediaBrowser.Model.System;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Rssdp; using Rssdp;
using Rssdp.Infrastructure; using Rssdp.Infrastructure;
using OperatingSystem = MediaBrowser.Common.System.OperatingSystem;
namespace Emby.Dlna.Main namespace Emby.Dlna.Main
{ {
@ -52,7 +52,6 @@ namespace Emby.Dlna.Main
private readonly ISocketFactory _socketFactory; private readonly ISocketFactory _socketFactory;
private readonly INetworkManager _networkManager; private readonly INetworkManager _networkManager;
private readonly object _syncLock = new object(); private readonly object _syncLock = new object();
private readonly NetworkConfiguration _netConfig;
private readonly bool _disabled; private readonly bool _disabled;
private PlayToManager _manager; private PlayToManager _manager;
@ -125,8 +124,8 @@ namespace Emby.Dlna.Main
config); config);
Current = this; Current = this;
_netConfig = config.GetConfiguration<NetworkConfiguration>("network"); var netConfig = config.GetConfiguration<NetworkConfiguration>("network");
_disabled = appHost.ListenWithHttps && _netConfig.RequireHttps; _disabled = appHost.ListenWithHttps && netConfig.RequireHttps;
if (_disabled && _config.GetDlnaConfiguration().EnableServer) if (_disabled && _config.GetDlnaConfiguration().EnableServer)
{ {
@ -202,8 +201,8 @@ namespace Emby.Dlna.Main
{ {
if (_communicationsServer == null) if (_communicationsServer == null)
{ {
var enableMultiSocketBinding = OperatingSystem.Id == OperatingSystemId.Windows || var enableMultiSocketBinding = OperatingSystem.IsWindows() ||
OperatingSystem.Id == OperatingSystemId.Linux; OperatingSystem.IsLinux();
_communicationsServer = new SsdpCommunicationsServer(_socketFactory, _networkManager, _logger, enableMultiSocketBinding) _communicationsServer = new SsdpCommunicationsServer(_socketFactory, _networkManager, _logger, enableMultiSocketBinding)
{ {
@ -219,11 +218,6 @@ namespace Emby.Dlna.Main
} }
} }
private void LogMessage(string msg)
{
_logger.LogDebug(msg);
}
private void StartDeviceDiscovery(ISsdpCommunicationsServer communicationsServer) private void StartDeviceDiscovery(ISsdpCommunicationsServer communicationsServer)
{ {
try try
@ -266,9 +260,13 @@ namespace Emby.Dlna.Main
try try
{ {
_publisher = new SsdpDevicePublisher(_communicationsServer, _networkManager, OperatingSystem.Name, Environment.OSVersion.VersionString, _config.GetDlnaConfiguration().SendOnlyMatchedHost) _publisher = new SsdpDevicePublisher(
_communicationsServer,
MediaBrowser.Common.System.OperatingSystem.Name,
Environment.OSVersion.VersionString,
_config.GetDlnaConfiguration().SendOnlyMatchedHost)
{ {
LogFunction = LogMessage, LogFunction = (msg) => _logger.LogDebug("{Msg}", msg),
SupportPnpRootDevice = false SupportPnpRootDevice = false
}; };
@ -313,15 +311,9 @@ namespace Emby.Dlna.Main
var fullService = "urn:schemas-upnp-org:device:MediaServer:1"; var fullService = "urn:schemas-upnp-org:device:MediaServer:1";
_logger.LogInformation("Registering publisher for {0} on {1}", fullService, address); _logger.LogInformation("Registering publisher for {ResourceName} on {DeviceAddress}", fullService, address);
var uri = new UriBuilder(_appHost.GetSmartApiUrl(address.Address) + descriptorUri); var uri = new UriBuilder(_appHost.GetApiUrlForLocalAccess(false) + descriptorUri);
if (!string.IsNullOrEmpty(_appHost.PublishedServerUrl))
{
// DLNA will only work over http, so we must reset to http:// : {port}.
uri.Scheme = "http";
uri.Port = _netConfig.HttpServerPortNumber;
}
var device = new SsdpRootDevice var device = new SsdpRootDevice
{ {
@ -407,7 +399,6 @@ namespace Emby.Dlna.Main
_imageProcessor, _imageProcessor,
_deviceDiscovery, _deviceDiscovery,
_httpClientFactory, _httpClientFactory,
_config,
_userDataManager, _userDataManager,
_localization, _localization,
_mediaSourceManager, _mediaSourceManager,

@ -1,3 +1,5 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@ -18,8 +20,6 @@ namespace Emby.Dlna.PlayTo
{ {
public class Device : IDisposable public class Device : IDisposable
{ {
private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger _logger; private readonly ILogger _logger;
@ -368,6 +368,42 @@ namespace Emby.Dlna.PlayTo
RestartTimer(true); RestartTimer(true);
} }
/*
* SetNextAvTransport is used to specify to the DLNA device what is the next track to play.
* Without that information, the next track command on the device does not work.
*/
public async Task SetNextAvTransport(string url, string header, string metaData, CancellationToken cancellationToken = default)
{
var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false);
url = url.Replace("&", "&amp;", StringComparison.Ordinal);
_logger.LogDebug("{PropertyName} - SetNextAvTransport Uri: {Url} DlnaHeaders: {Header}", Properties.Name, url, header);
var command = avCommands.ServiceActions.FirstOrDefault(c => string.Equals(c.Name, "SetNextAVTransportURI", StringComparison.OrdinalIgnoreCase));
if (command == null)
{
return;
}
var dictionary = new Dictionary<string, string>
{
{ "NextURI", url },
{ "NextURIMetaData", CreateDidlMeta(metaData) }
};
var service = GetAvTransportService();
if (service == null)
{
throw new InvalidOperationException("Unable to find service");
}
var post = avCommands.BuildPost(command, service.ServiceType, url, dictionary);
await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, post, header: header, cancellationToken)
.ConfigureAwait(false);
}
private static string CreateDidlMeta(string value) private static string CreateDidlMeta(string value)
{ {
if (string.IsNullOrEmpty(value)) if (string.IsNullOrEmpty(value))
@ -602,7 +638,7 @@ namespace Emby.Dlna.PlayTo
return; return;
} }
Volume = int.Parse(volumeValue, UsCulture); Volume = int.Parse(volumeValue, CultureInfo.InvariantCulture);
if (Volume > 0) if (Volume > 0)
{ {
@ -804,7 +840,7 @@ namespace Emby.Dlna.PlayTo
if (!string.IsNullOrWhiteSpace(duration) if (!string.IsNullOrWhiteSpace(duration)
&& !string.Equals(duration, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase)) && !string.Equals(duration, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase))
{ {
Duration = TimeSpan.Parse(duration, UsCulture); Duration = TimeSpan.Parse(duration, CultureInfo.InvariantCulture);
} }
else else
{ {
@ -816,7 +852,7 @@ namespace Emby.Dlna.PlayTo
if (!string.IsNullOrWhiteSpace(position) && !string.Equals(position, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrWhiteSpace(position) && !string.Equals(position, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase))
{ {
Position = TimeSpan.Parse(position, UsCulture); Position = TimeSpan.Parse(position, CultureInfo.InvariantCulture);
} }
var track = result.Document.Descendants("TrackMetaData").FirstOrDefault(); var track = result.Document.Descendants("TrackMetaData").FirstOrDefault();
@ -1156,8 +1192,8 @@ namespace Emby.Dlna.PlayTo
var depth = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("depth")); var depth = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("depth"));
var url = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("url")); var url = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("url"));
var widthValue = int.Parse(width, NumberStyles.Integer, UsCulture); var widthValue = int.Parse(width, NumberStyles.Integer, CultureInfo.InvariantCulture);
var heightValue = int.Parse(height, NumberStyles.Integer, UsCulture); var heightValue = int.Parse(height, NumberStyles.Integer, CultureInfo.InvariantCulture);
return new DeviceIcon return new DeviceIcon
{ {
@ -1222,10 +1258,7 @@ namespace Emby.Dlna.PlayTo
return; return;
} }
PlaybackStart?.Invoke(this, new PlaybackStartEventArgs PlaybackStart?.Invoke(this, new PlaybackStartEventArgs(mediaInfo));
{
MediaInfo = mediaInfo
});
} }
private void OnPlaybackProgress(UBaseObject mediaInfo) private void OnPlaybackProgress(UBaseObject mediaInfo)
@ -1235,27 +1268,17 @@ namespace Emby.Dlna.PlayTo
return; return;
} }
PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs(mediaInfo));
{
MediaInfo = mediaInfo
});
} }
private void OnPlaybackStop(UBaseObject mediaInfo) private void OnPlaybackStop(UBaseObject mediaInfo)
{ {
PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs(mediaInfo));
{
MediaInfo = mediaInfo
});
} }
private void OnMediaChanged(UBaseObject old, UBaseObject newMedia) private void OnMediaChanged(UBaseObject old, UBaseObject newMedia)
{ {
MediaChanged?.Invoke(this, new MediaChangedEventArgs MediaChanged?.Invoke(this, new MediaChangedEventArgs(old, newMedia));
{
OldMediaInfo = old,
NewMediaInfo = newMedia
});
} }
/// <inheritdoc /> /// <inheritdoc />

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

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

@ -1,3 +1,5 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@ -28,8 +30,6 @@ namespace Emby.Dlna.PlayTo
{ {
public class PlayToController : ISessionController, IDisposable public class PlayToController : ISessionController, IDisposable
{ {
private static readonly CultureInfo _usCulture = CultureInfo.ReadOnly(new CultureInfo("en-US"));
private readonly SessionInfo _session; private readonly SessionInfo _session;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
@ -102,6 +102,22 @@ namespace Emby.Dlna.PlayTo
_deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft; _deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft;
} }
/*
* Send a message to the DLNA device to notify what is the next track in the playlist.
*/
private async Task SendNextTrackMessage(int currentPlayListItemIndex, CancellationToken cancellationToken)
{
if (currentPlayListItemIndex >= 0 && currentPlayListItemIndex < _playlist.Count - 1)
{
// The current playing item is indeed in the play list and we are not yet at the end of the playlist.
var nextItemIndex = currentPlayListItemIndex + 1;
var nextItem = _playlist[nextItemIndex];
// Send the SetNextAvTransport message.
await _device.SetNextAvTransport(nextItem.StreamUrl, GetDlnaHeaders(nextItem), nextItem.Didl, cancellationToken).ConfigureAwait(false);
}
}
private void OnDeviceUnavailable() private void OnDeviceUnavailable()
{ {
try try
@ -156,6 +172,15 @@ namespace Emby.Dlna.PlayTo
var newItemProgress = GetProgressInfo(streamInfo); var newItemProgress = GetProgressInfo(streamInfo);
await _sessionManager.OnPlaybackStart(newItemProgress).ConfigureAwait(false); await _sessionManager.OnPlaybackStart(newItemProgress).ConfigureAwait(false);
// Send a message to the DLNA device to notify what is the next track in the playlist.
var currentItemIndex = _playlist.FindIndex(item => item.StreamInfo.ItemId == streamInfo.ItemId);
if (currentItemIndex >= 0)
{
_currentPlaylistIndex = currentItemIndex;
}
await SendNextTrackMessage(currentItemIndex, CancellationToken.None);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -425,6 +450,11 @@ namespace Emby.Dlna.PlayTo
var newItem = CreatePlaylistItem(info.Item, user, newPosition, info.MediaSourceId, info.AudioStreamIndex, info.SubtitleStreamIndex); var newItem = CreatePlaylistItem(info.Item, user, newPosition, info.MediaSourceId, info.AudioStreamIndex, info.SubtitleStreamIndex);
await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false); await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
// Send a message to the DLNA device to notify what is the next track in the play list.
var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl);
await SendNextTrackMessage(newItemIndex, CancellationToken.None);
return; return;
} }
@ -499,8 +529,8 @@ namespace Emby.Dlna.PlayTo
if (streamInfo.MediaType == DlnaProfileType.Audio) if (streamInfo.MediaType == DlnaProfileType.Audio)
{ {
return new ContentFeatureBuilder(profile) return ContentFeatureBuilder.BuildAudioHeader(
.BuildAudioHeader( profile,
streamInfo.Container, streamInfo.Container,
streamInfo.TargetAudioCodec.FirstOrDefault(), streamInfo.TargetAudioCodec.FirstOrDefault(),
streamInfo.TargetAudioBitrate, streamInfo.TargetAudioBitrate,
@ -514,8 +544,8 @@ namespace Emby.Dlna.PlayTo
if (streamInfo.MediaType == DlnaProfileType.Video) if (streamInfo.MediaType == DlnaProfileType.Video)
{ {
var list = new ContentFeatureBuilder(profile) var list = ContentFeatureBuilder.BuildVideoHeader(
.BuildVideoHeader( profile,
streamInfo.Container, streamInfo.Container,
streamInfo.TargetVideoCodec.FirstOrDefault(), streamInfo.TargetVideoCodec.FirstOrDefault(),
streamInfo.TargetAudioCodec.FirstOrDefault(), streamInfo.TargetAudioCodec.FirstOrDefault(),
@ -623,6 +653,9 @@ namespace Emby.Dlna.PlayTo
await _device.SetAvTransport(currentitem.StreamUrl, GetDlnaHeaders(currentitem), currentitem.Didl, cancellationToken).ConfigureAwait(false); await _device.SetAvTransport(currentitem.StreamUrl, GetDlnaHeaders(currentitem), currentitem.Didl, cancellationToken).ConfigureAwait(false);
// Send a message to the DLNA device to notify what is the next track in the play list.
await SendNextTrackMessage(index, cancellationToken);
var streamInfo = currentitem.StreamInfo; var streamInfo = currentitem.StreamInfo;
if (streamInfo.StartPositionTicks > 0 && EnableClientSideSeek(streamInfo)) if (streamInfo.StartPositionTicks > 0 && EnableClientSideSeek(streamInfo))
{ {
@ -681,7 +714,7 @@ namespace Emby.Dlna.PlayTo
case GeneralCommandType.SetAudioStreamIndex: case GeneralCommandType.SetAudioStreamIndex:
if (command.Arguments.TryGetValue("Index", out string index)) if (command.Arguments.TryGetValue("Index", out string index))
{ {
if (int.TryParse(index, NumberStyles.Integer, _usCulture, out var val)) if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{ {
return SetAudioStreamIndex(val); return SetAudioStreamIndex(val);
} }
@ -693,7 +726,7 @@ namespace Emby.Dlna.PlayTo
case GeneralCommandType.SetSubtitleStreamIndex: case GeneralCommandType.SetSubtitleStreamIndex:
if (command.Arguments.TryGetValue("Index", out index)) if (command.Arguments.TryGetValue("Index", out index))
{ {
if (int.TryParse(index, NumberStyles.Integer, _usCulture, out var val)) if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{ {
return SetSubtitleStreamIndex(val); return SetSubtitleStreamIndex(val);
} }
@ -705,7 +738,7 @@ namespace Emby.Dlna.PlayTo
case GeneralCommandType.SetVolume: case GeneralCommandType.SetVolume:
if (command.Arguments.TryGetValue("Volume", out string vol)) if (command.Arguments.TryGetValue("Volume", out string vol))
{ {
if (int.TryParse(vol, NumberStyles.Integer, _usCulture, out var volume)) if (int.TryParse(vol, NumberStyles.Integer, CultureInfo.InvariantCulture, out var volume))
{ {
return _device.SetVolume(volume, cancellationToken); return _device.SetVolume(volume, cancellationToken);
} }
@ -736,6 +769,10 @@ namespace Emby.Dlna.PlayTo
await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false); await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
// Send a message to the DLNA device to notify what is the next track in the play list.
var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl);
await SendNextTrackMessage(newItemIndex, CancellationToken.None);
if (EnableClientSideSeek(newItem.StreamInfo)) if (EnableClientSideSeek(newItem.StreamInfo))
{ {
await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false); await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
@ -761,6 +798,10 @@ namespace Emby.Dlna.PlayTo
await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false); await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
// Send a message to the DLNA device to notify what is the next track in the play list.
var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl);
await SendNextTrackMessage(newItemIndex, CancellationToken.None);
if (EnableClientSideSeek(newItem.StreamInfo) && newPosition > 0) if (EnableClientSideSeek(newItem.StreamInfo) && newPosition > 0)
{ {
await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false); await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);

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

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

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

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

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

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

@ -1,3 +1,5 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@ -18,8 +20,6 @@ namespace Emby.Dlna.PlayTo
private const string USERAGENT = "Microsoft-Windows/6.2 UPnP/1.0 Microsoft-DLNA DLNADOC/1.50"; private const string USERAGENT = "Microsoft-Windows/6.2 UPnP/1.0 Microsoft-DLNA DLNADOC/1.50";
private const string FriendlyName = "Jellyfin"; private const string FriendlyName = "Jellyfin";
private readonly CultureInfo _usCulture = new CultureInfo("en-US");
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
public SsdpHttpClient(IHttpClientFactory httpClientFactory) public SsdpHttpClient(IHttpClientFactory httpClientFactory)
@ -43,10 +43,12 @@ namespace Emby.Dlna.PlayTo
header, header,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await XDocument.LoadAsync( return await XDocument.LoadAsync(
stream, stream,
LoadOptions.PreserveWhitespace, LoadOptions.None,
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
} }
@ -76,14 +78,15 @@ namespace Emby.Dlna.PlayTo
{ {
using var options = new HttpRequestMessage(new HttpMethod("SUBSCRIBE"), url); using var options = new HttpRequestMessage(new HttpMethod("SUBSCRIBE"), url);
options.Headers.UserAgent.ParseAdd(USERAGENT); options.Headers.UserAgent.ParseAdd(USERAGENT);
options.Headers.TryAddWithoutValidation("HOST", ip + ":" + port.ToString(_usCulture)); options.Headers.TryAddWithoutValidation("HOST", ip + ":" + port.ToString(CultureInfo.InvariantCulture));
options.Headers.TryAddWithoutValidation("CALLBACK", "<" + localIp + ":" + eventport.ToString(_usCulture) + ">"); options.Headers.TryAddWithoutValidation("CALLBACK", "<" + localIp + ":" + eventport.ToString(CultureInfo.InvariantCulture) + ">");
options.Headers.TryAddWithoutValidation("NT", "upnp:event"); options.Headers.TryAddWithoutValidation("NT", "upnp:event");
options.Headers.TryAddWithoutValidation("TIMEOUT", "Second-" + timeOut.ToString(_usCulture)); options.Headers.TryAddWithoutValidation("TIMEOUT", "Second-" + timeOut.ToString(CultureInfo.InvariantCulture));
using var response = await _httpClientFactory.CreateClient(NamedClient.Default) using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.SendAsync(options, HttpCompletionOption.ResponseHeadersRead) .SendAsync(options, HttpCompletionOption.ResponseHeadersRead)
.ConfigureAwait(false); .ConfigureAwait(false);
response.EnsureSuccessStatusCode();
} }
public async Task<XDocument> GetDataAsync(string url, CancellationToken cancellationToken) public async Task<XDocument> GetDataAsync(string url, CancellationToken cancellationToken)
@ -92,12 +95,13 @@ namespace Emby.Dlna.PlayTo
options.Headers.UserAgent.ParseAdd(USERAGENT); options.Headers.UserAgent.ParseAdd(USERAGENT);
options.Headers.TryAddWithoutValidation("FriendlyName.DLNA.ORG", FriendlyName); options.Headers.TryAddWithoutValidation("FriendlyName.DLNA.ORG", FriendlyName);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
return await XDocument.LoadAsync( return await XDocument.LoadAsync(
stream, stream,
LoadOptions.PreserveWhitespace, LoadOptions.None,
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
} }
catch catch

@ -46,7 +46,7 @@ namespace Emby.Dlna.PlayTo
{ {
var serviceAction = new ServiceAction var serviceAction = new ServiceAction
{ {
Name = container.GetValue(UPnpNamespaces.Svc + "name"), Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty,
}; };
var argumentList = serviceAction.ArgumentList; var argumentList = serviceAction.ArgumentList;
@ -68,9 +68,9 @@ namespace Emby.Dlna.PlayTo
return new Argument return new Argument
{ {
Name = container.GetValue(UPnpNamespaces.Svc + "name"), Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty,
Direction = container.GetValue(UPnpNamespaces.Svc + "direction"), Direction = container.GetValue(UPnpNamespaces.Svc + "direction") ?? string.Empty,
RelatedStateVariable = container.GetValue(UPnpNamespaces.Svc + "relatedStateVariable") RelatedStateVariable = container.GetValue(UPnpNamespaces.Svc + "relatedStateVariable") ?? string.Empty
}; };
} }
@ -89,8 +89,8 @@ namespace Emby.Dlna.PlayTo
return new StateVariable return new StateVariable
{ {
Name = container.GetValue(UPnpNamespaces.Svc + "name"), Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty,
DataType = container.GetValue(UPnpNamespaces.Svc + "dataType"), DataType = container.GetValue(UPnpNamespaces.Svc + "dataType") ?? string.Empty,
AllowedValues = allowedValues AllowedValues = allowedValues
}; };
} }
@ -166,7 +166,7 @@ namespace Emby.Dlna.PlayTo
return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamesapce, stateString); return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamesapce, stateString);
} }
private string BuildArgumentXml(Argument argument, string value, string commandParameter = "") private string BuildArgumentXml(Argument argument, string? value, string commandParameter = "")
{ {
var state = StateVariables.FirstOrDefault(a => string.Equals(a.Name, argument.RelatedStateVariable, StringComparison.OrdinalIgnoreCase)); var state = StateVariables.FirstOrDefault(a => string.Equals(a.Name, argument.RelatedStateVariable, StringComparison.OrdinalIgnoreCase));

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

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

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

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

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

@ -1,3 +1,5 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@ -104,7 +106,7 @@ namespace Emby.Dlna.Ssdp
{ {
Location = e.DiscoveredDevice.DescriptionLocation, Location = e.DiscoveredDevice.DescriptionLocation,
Headers = headers, Headers = headers,
LocalIpAddress = e.LocalIpAddress RemoteIpAddress = e.RemoteIpAddress
}); });
DeviceDiscoveredInternal?.Invoke(this, args); DeviceDiscoveredInternal?.Invoke(this, args);

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

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

@ -26,7 +26,7 @@ namespace Emby.Drawing
public sealed class ImageProcessor : IImageProcessor, IDisposable public sealed class ImageProcessor : IImageProcessor, IDisposable
{ {
// Increment this when there's a change requiring caches to be invalidated // Increment this when there's a change requiring caches to be invalidated
private const string Version = "3"; private const char Version = '3';
private static readonly HashSet<string> _transparentImageTypes private static readonly HashSet<string> _transparentImageTypes
= new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" }; = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
@ -102,7 +102,7 @@ namespace Emby.Drawing
{ {
var file = await ProcessImage(options).ConfigureAwait(false); var file = await ProcessImage(options).ConfigureAwait(false);
using (var fileStream = new FileStream(file.Item1, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, true)) using (var fileStream = AsyncFile.OpenRead(file.Item1))
{ {
await fileStream.CopyToAsync(toStream).ConfigureAwait(false); await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
} }

@ -1,7 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using Emby.Naming.Common; using Emby.Naming.Common;
using Jellyfin.Extensions;
namespace Emby.Naming.Audio namespace Emby.Naming.Audio
{ {
@ -18,8 +18,8 @@ namespace Emby.Naming.Audio
/// <returns>True if file at path is audio file.</returns> /// <returns>True if file at path is audio file.</returns>
public static bool IsAudioFile(string path, NamingOptions options) public static bool IsAudioFile(string path, NamingOptions options)
{ {
var extension = Path.GetExtension(path); var extension = Path.GetExtension(path.AsSpan());
return options.AudioFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); return options.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
} }
} }
} }

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

@ -87,7 +87,7 @@ namespace Emby.Naming.AudioBook
foreach (var audioFile in group) foreach (var audioFile in group)
{ {
var name = Path.GetFileNameWithoutExtension(audioFile.Path); var name = Path.GetFileNameWithoutExtension(audioFile.Path);
if (name.Equals("audiobook") || if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) ||
name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) || name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) ||
name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase)) name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase))
{ {

@ -1,3 +1,5 @@
#pragma warning disable CA1819
using System; using System;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@ -137,8 +139,11 @@ namespace Emby.Naming.Common
CleanStrings = new[] CleanStrings = new[]
{ {
@"[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", @"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
@"(\[.*\])" @"^(?<cleaned>.+?)(\[.*\])",
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
@"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$"
}; };
SubtitleFileExtensions = new[] SubtitleFileExtensions = new[]
@ -250,6 +255,8 @@ namespace Emby.Naming.Common
}, },
// <!-- foo.ep01, foo.EP_01 --> // <!-- foo.ep01, foo.EP_01 -->
new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"), new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"),
// <!-- foo.E01., foo.e01. -->
new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"),
new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true) new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true)
{ {
DateTimeFormats = new[] DateTimeFormats = new[]
@ -277,14 +284,14 @@ namespace Emby.Naming.Common
IsNamed = true IsNamed = true
}, },
new EpisodeExpression("[\\\\/\\._ \\[\\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\\.[1-9])(?![0-9]))?)([^\\\\/]*)$") new EpisodeExpression(@"[\\\/\._ \[\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\.[1-9])(?![0-9]))?)([^\\\/]*)$")
{ {
SupportsAbsoluteEpisodeNumbers = true SupportsAbsoluteEpisodeNumbers = true
}, },
// Not a Kodi rule as well, but below rule also causes false positives for triple-digit episode names // 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 // [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 IsNamed = true
}, },
@ -305,6 +312,12 @@ namespace Emby.Naming.Common
// *** End Kodi Standard Naming // *** 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]+)[^\\\/]*$") new EpisodeExpression(@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]+)[xX](?<epnumber>[0-9]+)[^\\\/]*$")
{ {
IsNamed = true IsNamed = true
@ -362,12 +375,20 @@ namespace Emby.Naming.Common
IsOptimistic = true, IsOptimistic = true,
IsNamed = true IsNamed = true
}, },
// "Episode 16", "Episode 16 - Title"
new EpisodeExpression(@".*[\\\/][^\\\/]* (?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*[^\\\/]*$") // Series and season only expression
// "the show/season 1", "the show/s01"
new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)\/[Ss](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)")
{ {
IsOptimistic = true,
IsNamed = true IsNamed = true
} },
// Series and season only expression
// "the show S01", "the show season 1"
new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)[\. _\-]+[sS](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)")
{
IsNamed = true
},
}; };
EpisodeWithoutSeasonExpressions = new[] EpisodeWithoutSeasonExpressions = new[]
@ -478,6 +499,12 @@ namespace Emby.Naming.Common
"-deleted", "-deleted",
MediaType.Video), MediaType.Video),
new ExtraRule(
ExtraType.DeletedScene,
ExtraRuleType.Suffix,
"-deletedscene",
MediaType.Video),
new ExtraRule( new ExtraRule(
ExtraType.Clip, ExtraType.Clip,
ExtraRuleType.Suffix, ExtraRuleType.Suffix,

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<!-- ProjectGuid is only included as a requirement for SonarQube analysis --> <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup> <PropertyGroup>
@ -6,15 +6,13 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PublishRepositoryUrl>true</PublishRepositoryUrl> <PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources> <EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols> <IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat> <SymbolPackageFormat>snupkg</SymbolPackageFormat>
<Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'"> <PropertyGroup Condition=" '$(Stability)'=='Unstable'">
@ -23,11 +21,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Include="..\SharedVersion.cs" /> <Compile Include="../SharedVersion.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> <ProjectReference Include="../MediaBrowser.Common/MediaBrowser.Common.csproj" />
<ProjectReference Include="../MediaBrowser.Model/MediaBrowser.Model.csproj" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@ -39,7 +38,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
</ItemGroup> </ItemGroup>
<!-- Code Analyzers--> <!-- Code Analyzers-->
@ -49,8 +48,4 @@
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup> </ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
</Project> </Project>

@ -16,7 +16,7 @@ namespace Emby.Naming.TV
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="EpisodeResolver"/> class. /// Initializes a new instance of the <see cref="EpisodeResolver"/> class.
/// </summary> /// </summary>
/// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions and passed to <see cref="StubResolver"/>, <see cref="FlagParser"/>, <see cref="Format3DParser"/> and <see cref="EpisodePathParser"/>.</param> /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions and passed to <see cref="StubResolver"/>, <see cref="Format3DParser"/> and <see cref="EpisodePathParser"/>.</param>
public EpisodeResolver(NamingOptions options) public EpisodeResolver(NamingOptions options)
{ {
_options = options; _options = options;
@ -62,12 +62,16 @@ namespace Emby.Naming.TV
container = extension.TrimStart('.'); container = extension.TrimStart('.');
} }
var flags = new FlagParser(_options).GetFlags(path); var format3DResult = Format3DParser.Parse(path, _options);
var format3DResult = new Format3DParser(_options).Parse(flags);
var parsingResult = new EpisodePathParser(_options) var parsingResult = new EpisodePathParser(_options)
.Parse(path, isDirectory, isNamed, isOptimistic, supportsAbsoluteNumbers, fillExtendedInfo); .Parse(path, isDirectory, isNamed, isOptimistic, supportsAbsoluteNumbers, fillExtendedInfo);
if (!parsingResult.Success && !isStub)
{
return null;
}
return new EpisodeInfo(path) return new EpisodeInfo(path)
{ {
Container = container, Container = container,

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

@ -0,0 +1,61 @@
using System.Globalization;
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;
}
}
}

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

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

@ -17,38 +17,39 @@ namespace Emby.Naming.Video
/// <param name="expressions">List of regex to parse name and year from.</param> /// <param name="expressions">List of regex to parse name and year from.</param>
/// <param name="newName">Parsing result string.</param> /// <param name="newName">Parsing result string.</param>
/// <returns>True if parsing was successful.</returns> /// <returns>True if parsing was successful.</returns>
public static bool TryClean([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)) if (string.IsNullOrEmpty(name))
{ {
newName = ReadOnlySpan<char>.Empty; newName = string.Empty;
return false; return false;
} }
var len = expressions.Count; // Iteratively apply the regexps to clean the string.
for (int i = 0; i < len; i++) bool cleaned = false;
for (int i = 0; i < expressions.Count; i++)
{ {
if (TryClean(name, expressions[i], out newName)) if (TryClean(name, expressions[i], out newName))
{ {
return true; cleaned = true;
name = newName;
} }
} }
newName = ReadOnlySpan<char>.Empty; newName = cleaned ? name : string.Empty;
return false; return cleaned;
} }
private static bool TryClean(string name, Regex expression, out ReadOnlySpan<char> newName) private static bool TryClean(string name, Regex expression, out string newName)
{ {
var match = expression.Match(name); var match = expression.Match(name);
int index = match.Index; if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned))
if (match.Success && index != 0)
{ {
newName = name.AsSpan().Slice(0, match.Index); newName = cleaned.Value;
return true; return true;
} }
newName = ReadOnlySpan<char>.Empty; newName = string.Empty;
return false; return false;
} }
} }

@ -1,6 +1,5 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Emby.Naming.Audio; using Emby.Naming.Audio;
using Emby.Naming.Common; using Emby.Naming.Common;
@ -12,6 +11,7 @@ namespace Emby.Naming.Video
/// </summary> /// </summary>
public class ExtraResolver public class ExtraResolver
{ {
private static readonly char[] _digits = new[] { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
private readonly NamingOptions _options; private readonly NamingOptions _options;
/// <summary> /// <summary>
@ -29,70 +29,74 @@ namespace Emby.Naming.Video
/// <param name="path">Path to file.</param> /// <param name="path">Path to file.</param>
/// <returns>Returns <see cref="ExtraResult"/> object.</returns> /// <returns>Returns <see cref="ExtraResult"/> object.</returns>
public ExtraResult GetExtraInfo(string path) public ExtraResult GetExtraInfo(string path)
{
return _options.VideoExtraRules
.Select(i => GetExtraInfo(path, i))
.FirstOrDefault(i => i.ExtraType != null) ?? new ExtraResult();
}
private ExtraResult GetExtraInfo(string path, ExtraRule rule)
{ {
var result = new ExtraResult(); var result = new ExtraResult();
if (rule.MediaType == MediaType.Audio) for (var i = 0; i < _options.VideoExtraRules.Length; i++)
{ {
if (!AudioFileParser.IsAudioFile(path, _options)) var rule = _options.VideoExtraRules[i];
if (rule.MediaType == MediaType.Audio)
{ {
return result; if (!AudioFileParser.IsAudioFile(path, _options))
{
continue;
}
} }
} else if (rule.MediaType == MediaType.Video)
else if (rule.MediaType == MediaType.Video)
{
if (!new VideoResolver(_options).IsVideoFile(path))
{ {
return result; if (!VideoResolver.IsVideoFile(path, _options))
{
continue;
}
} }
}
if (rule.RuleType == ExtraRuleType.Filename)
{
var filename = Path.GetFileNameWithoutExtension(path);
if (string.Equals(filename, rule.Token, StringComparison.OrdinalIgnoreCase)) var pathSpan = path.AsSpan();
if (rule.RuleType == ExtraRuleType.Filename)
{ {
result.ExtraType = rule.ExtraType; var filename = Path.GetFileNameWithoutExtension(pathSpan);
result.Rule = rule;
}
}
else if (rule.RuleType == ExtraRuleType.Suffix)
{
var filename = Path.GetFileNameWithoutExtension(path);
if (filename.IndexOf(rule.Token, StringComparison.OrdinalIgnoreCase) > 0) if (filename.Equals(rule.Token, StringComparison.OrdinalIgnoreCase))
{
result.ExtraType = rule.ExtraType;
result.Rule = rule;
}
}
else if (rule.RuleType == ExtraRuleType.Suffix)
{ {
result.ExtraType = rule.ExtraType; // Trim the digits from the end of the filename so we can recognize things like -trailer2
result.Rule = rule; var filename = Path.GetFileNameWithoutExtension(pathSpan).TrimEnd(_digits);
if (filename.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase))
{
result.ExtraType = rule.ExtraType;
result.Rule = rule;
}
} }
} else if (rule.RuleType == ExtraRuleType.Regex)
else if (rule.RuleType == ExtraRuleType.Regex) {
{ var filename = Path.GetFileName(path);
var filename = Path.GetFileName(path);
var regex = new Regex(rule.Token, RegexOptions.IgnoreCase); var regex = new Regex(rule.Token, RegexOptions.IgnoreCase);
if (regex.IsMatch(filename)) if (regex.IsMatch(filename))
{
result.ExtraType = rule.ExtraType;
result.Rule = rule;
}
}
else if (rule.RuleType == ExtraRuleType.DirectoryName)
{ {
result.ExtraType = rule.ExtraType; var directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan));
result.Rule = rule; if (directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase))
{
result.ExtraType = rule.ExtraType;
result.Rule = rule;
}
} }
}
else if (rule.RuleType == ExtraRuleType.DirectoryName) if (result.ExtraType != null)
{
var directoryName = Path.GetFileName(Path.GetDirectoryName(path));
if (string.Equals(directoryName, rule.Token, StringComparison.OrdinalIgnoreCase))
{ {
result.ExtraType = rule.ExtraType; return result;
result.Rule = rule;
} }
} }

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

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

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

@ -85,10 +85,8 @@ namespace Emby.Naming.Video
/// <returns>Enumerable <see cref="FileStack"/> of videos.</returns> /// <returns>Enumerable <see cref="FileStack"/> of videos.</returns>
public IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files) public IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files)
{ {
var resolver = new VideoResolver(_options);
var list = files var list = files
.Where(i => i.IsDirectory || resolver.IsVideoFile(i.FullName) || resolver.IsStubFile(i.FullName)) .Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, _options) || VideoResolver.IsStubFile(i.FullName, _options))
.OrderBy(i => i.FullName) .OrderBy(i => i.FullName)
.ToList(); .ToList();

@ -1,3 +1,4 @@
using System;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
namespace Emby.Naming.Video namespace Emby.Naming.Video
@ -106,9 +107,9 @@ namespace Emby.Naming.Video
/// Gets the file name without extension. /// Gets the file name without extension.
/// </summary> /// </summary>
/// <value>The file name without extension.</value> /// <value>The file name without extension.</value>
public string FileNameWithoutExtension => !IsDirectory public ReadOnlySpan<char> FileNameWithoutExtension => !IsDirectory
? System.IO.Path.GetFileNameWithoutExtension(Path) ? System.IO.Path.GetFileNameWithoutExtension(Path.AsSpan())
: System.IO.Path.GetFileName(Path); : System.IO.Path.GetFileName(Path.AsSpan());
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() public override string ToString()

@ -12,31 +12,19 @@ namespace Emby.Naming.Video
/// <summary> /// <summary>
/// Resolves alternative versions and extras from list of video files. /// Resolves alternative versions and extras from list of video files.
/// </summary> /// </summary>
public class VideoListResolver public static class VideoListResolver
{ {
private readonly NamingOptions _options;
/// <summary>
/// Initializes a new instance of the <see cref="VideoListResolver"/> class.
/// </summary>
/// <param name="options"><see cref="NamingOptions"/> object containing CleanStringRegexes and VideoFlagDelimiters and passes options to <see cref="StackResolver"/> and <see cref="VideoResolver"/>.</param>
public VideoListResolver(NamingOptions options)
{
_options = options;
}
/// <summary> /// <summary>
/// Resolves alternative versions and extras from list of video files. /// Resolves alternative versions and extras from list of video files.
/// </summary> /// </summary>
/// <param name="files">List of related video files.</param> /// <param name="files">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="supportMultiVersion">Indication we should consider multi-versions of content.</param>
/// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns> /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
public IEnumerable<VideoInfo> Resolve(List<FileSystemMetadata> files, bool supportMultiVersion = true) public static IEnumerable<VideoInfo> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions, bool supportMultiVersion = true)
{ {
var videoResolver = new VideoResolver(_options);
var videoInfos = files var videoInfos = files
.Select(i => videoResolver.Resolve(i.FullName, i.IsDirectory)) .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions))
.OfType<VideoFileInfo>() .OfType<VideoFileInfo>()
.ToList(); .ToList();
@ -46,7 +34,7 @@ namespace Emby.Naming.Video
.Where(i => i.ExtraType == null) .Where(i => i.ExtraType == null)
.Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
var stackResult = new StackResolver(_options) var stackResult = new StackResolver(namingOptions)
.Resolve(nonExtras).ToList(); .Resolve(nonExtras).ToList();
var remainingFiles = videoInfos var remainingFiles = videoInfos
@ -59,23 +47,17 @@ namespace Emby.Naming.Video
{ {
var info = new VideoInfo(stack.Name) var info = new VideoInfo(stack.Name)
{ {
Files = stack.Files.Select(i => videoResolver.Resolve(i, stack.IsDirectoryStack)) Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions))
.OfType<VideoFileInfo>() .OfType<VideoFileInfo>()
.ToList() .ToList()
}; };
info.Year = info.Files[0].Year; info.Year = info.Files[0].Year;
var extraBaseNames = new List<string> { stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0]) }; var extras = ExtractExtras(remainingFiles, stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0].AsSpan()), namingOptions.VideoFlagDelimiters);
var extras = GetExtras(remainingFiles, extraBaseNames);
if (extras.Count > 0) if (extras.Count > 0)
{ {
remainingFiles = remainingFiles
.Except(extras)
.ToList();
info.Extras = extras; info.Extras = extras;
} }
@ -88,15 +70,12 @@ namespace Emby.Naming.Video
foreach (var media in standaloneMedia) foreach (var media in standaloneMedia)
{ {
var info = new VideoInfo(media.Name) { Files = new List<VideoFileInfo> { media } }; var info = new VideoInfo(media.Name) { Files = new[] { media } };
info.Year = info.Files[0].Year; info.Year = info.Files[0].Year;
var extras = GetExtras(remainingFiles, new List<string> { media.FileNameWithoutExtension }); remainingFiles.Remove(media);
var extras = ExtractExtras(remainingFiles, media.FileNameWithoutExtension, namingOptions.VideoFlagDelimiters);
remainingFiles = remainingFiles
.Except(extras.Concat(new[] { media }))
.ToList();
info.Extras = extras; info.Extras = extras;
@ -105,8 +84,7 @@ namespace Emby.Naming.Video
if (supportMultiVersion) if (supportMultiVersion)
{ {
list = GetVideosGroupedByVersion(list) list = GetVideosGroupedByVersion(list, namingOptions);
.ToList();
} }
// If there's only one resolved video, use the folder name as well to find extras // If there's only one resolved video, use the folder name as well to find extras
@ -114,19 +92,14 @@ namespace Emby.Naming.Video
{ {
var info = list[0]; var info = list[0];
var videoPath = list[0].Files[0].Path; var videoPath = list[0].Files[0].Path;
var parentPath = Path.GetDirectoryName(videoPath); var parentPath = Path.GetDirectoryName(videoPath.AsSpan());
if (!string.IsNullOrEmpty(parentPath)) if (!parentPath.IsEmpty)
{ {
var folderName = Path.GetFileName(parentPath); var folderName = Path.GetFileName(parentPath);
if (!string.IsNullOrEmpty(folderName)) if (!folderName.IsEmpty)
{ {
var extras = GetExtras(remainingFiles, new List<string> { folderName }); var extras = ExtractExtras(remainingFiles, folderName, namingOptions.VideoFlagDelimiters);
remainingFiles = remainingFiles
.Except(extras)
.ToList();
extras.AddRange(info.Extras); extras.AddRange(info.Extras);
info.Extras = extras; info.Extras = extras;
} }
@ -164,96 +137,168 @@ namespace Emby.Naming.Video
// Whatever files are left, just add them // Whatever files are left, just add them
list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name) list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name)
{ {
Files = new List<VideoFileInfo> { i }, Files = new[] { i },
Year = i.Year Year = i.Year
})); }));
return list; return list;
} }
private IEnumerable<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos) private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions)
{ {
if (videos.Count == 0) if (videos.Count == 0)
{ {
return videos; return videos;
} }
var list = new List<VideoInfo>(); var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path.AsSpan()));
var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path));
if (!string.IsNullOrEmpty(folderName) if (folderName.Length <= 1 || !HaveSameYear(videos))
&& folderName.Length > 1
&& videos.All(i => i.Files.Count == 1
&& IsEligibleForMultiVersion(folderName, i.Files[0].Path))
&& HaveSameYear(videos))
{ {
var ordered = videos.OrderBy(i => i.Name).ToList(); return videos;
}
list.Add(ordered[0]);
var alternateVersionsLen = ordered.Count - 1; // Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the above [if]
var alternateVersions = new VideoFileInfo[alternateVersionsLen]; for (var i = 0; i < videos.Count; i++)
for (int i = 0; i < alternateVersionsLen; i++) {
var video = videos[i];
if (!IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions))
{ {
alternateVersions[i] = ordered[i + 1].Files[0]; return videos;
} }
}
// The list is created and overwritten in the caller, so we are allowed to do in-place sorting
videos.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.Ordinal));
list[0].AlternateVersions = alternateVersions; var list = new List<VideoInfo>
list[0].Name = folderName; {
var extras = ordered.Skip(1).SelectMany(i => i.Extras).ToList(); videos[0]
extras.AddRange(list[0].Extras); };
list[0].Extras = extras;
return list; 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);
} }
return videos; list[0].AlternateVersions = alternateVersions;
} list[0].Name = folderName.ToString();
list[0].Extras = extras;
private bool HaveSameYear(List<VideoInfo> videos) return list;
{
return videos.Select(i => i.Year ?? -1).Distinct().Count() < 2;
} }
private bool IsEligibleForMultiVersion(string folderName, string testFilePath) private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos)
{ {
string testFilename = Path.GetFileNameWithoutExtension(testFilePath); if (videos.Count == 1)
if (testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
{ {
// Remove the folder name before cleaning as we don't care about cleaning that part return true;
if (folderName.Length <= testFilename.Length) }
{
testFilename = testFilename.Substring(folderName.Length).Trim();
}
if (CleanStringParser.TryClean(testFilename, _options.CleanStringRegexes, out var cleanName)) var firstYear = videos[0].Year ?? -1;
for (var i = 1; i < videos.Count; i++)
{
if ((videos[i].Year ?? -1) != firstYear)
{ {
testFilename = cleanName.Trim().ToString(); return false;
} }
}
// The CleanStringParser should have removed common keywords etc. return true;
return string.IsNullOrEmpty(testFilename) }
|| testFilename[0] == '-'
|| Regex.IsMatch(testFilename, @"^\[([^]]*)\]"); private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, string testFilePath, NamingOptions namingOptions)
{
var testFilename = Path.GetFileNameWithoutExtension(testFilePath.AsSpan());
if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
{
return false;
} }
return false; // Remove the folder name before cleaning as we don't care about cleaning that part
if (folderName.Length <= testFilename.Length)
{
testFilename = testFilename[folderName.Length..].Trim();
}
// There are no span overloads for regex unfortunately
var tmpTestFilename = testFilename.ToString();
if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName))
{
tmpTestFilename = cleanName.Trim().ToString();
}
// The CleanStringParser should have removed common keywords etc.
return string.IsNullOrEmpty(tmpTestFilename)
|| 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 List<VideoFileInfo> GetExtras(IEnumerable<VideoFileInfo> remainingFiles, List<string> baseNames) private static bool StartsWith(ReadOnlySpan<char> fileName, ReadOnlySpan<char> baseName, ReadOnlySpan<char> trimmedBaseName)
{ {
foreach (var name in baseNames.ToList()) if (baseName.IsEmpty)
{ {
var trimmedName = name.TrimEnd().TrimEnd(_options.VideoFlagDelimiters).TrimEnd(); return false;
baseNames.Add(trimmedName);
} }
return remainingFiles return fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
.Where(i => i.ExtraType != null) || (!trimmedBaseName.IsEmpty && fileName.StartsWith(trimmedBaseName, StringComparison.OrdinalIgnoreCase));
.Where(i => baseNames.Any(b => }
i.FileNameWithoutExtension.StartsWith(b, StringComparison.OrdinalIgnoreCase)))
.ToList(); /// <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;
} }
} }
} }

@ -1,46 +1,36 @@
using System; using System;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Linq;
using Emby.Naming.Common; using Emby.Naming.Common;
using Jellyfin.Extensions;
namespace Emby.Naming.Video namespace Emby.Naming.Video
{ {
/// <summary> /// <summary>
/// Resolves <see cref="VideoFileInfo"/> from file path. /// Resolves <see cref="VideoFileInfo"/> from file path.
/// </summary> /// </summary>
public class VideoResolver public static class VideoResolver
{ {
private readonly NamingOptions _options;
/// <summary>
/// Initializes a new instance of the <see cref="VideoResolver"/> class.
/// </summary>
/// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions, StubFileExtensions, CleanStringRegexes and CleanDateTimeRegexes
/// and passes options in <see cref="StubResolver"/>, <see cref="FlagParser"/>, <see cref="Format3DParser"/> and <see cref="ExtraResolver"/>.</param>
public VideoResolver(NamingOptions options)
{
_options = options;
}
/// <summary> /// <summary>
/// Resolves the directory. /// Resolves the directory.
/// </summary> /// </summary>
/// <param name="path">The path.</param> /// <param name="path">The path.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>VideoFileInfo.</returns> /// <returns>VideoFileInfo.</returns>
public VideoFileInfo? ResolveDirectory(string? path) public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions)
{ {
return Resolve(path, true); return Resolve(path, true, namingOptions);
} }
/// <summary> /// <summary>
/// Resolves the file. /// Resolves the file.
/// </summary> /// </summary>
/// <param name="path">The path.</param> /// <param name="path">The path.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>VideoFileInfo.</returns> /// <returns>VideoFileInfo.</returns>
public VideoFileInfo? ResolveFile(string? path) public static VideoFileInfo? ResolveFile(string? path, NamingOptions namingOptions)
{ {
return Resolve(path, false); return Resolve(path, false, namingOptions);
} }
/// <summary> /// <summary>
@ -48,10 +38,11 @@ namespace Emby.Naming.Video
/// </summary> /// </summary>
/// <param name="path">The path.</param> /// <param name="path">The path.</param>
/// <param name="isDirectory">if set to <c>true</c> [is folder].</param> /// <param name="isDirectory">if set to <c>true</c> [is folder].</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="parseName">Whether or not the name should be parsed for info.</param> /// <param name="parseName">Whether or not the name should be parsed for info.</param>
/// <returns>VideoFileInfo.</returns> /// <returns>VideoFileInfo.</returns>
/// <exception cref="ArgumentNullException"><c>path</c> is <c>null</c>.</exception> /// <exception cref="ArgumentNullException"><c>path</c> is <c>null</c>.</exception>
public VideoFileInfo? Resolve(string? path, bool isDirectory, bool parseName = true) public static VideoFileInfo? Resolve(string? path, bool isDirectory, NamingOptions namingOptions, bool parseName = true)
{ {
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(path))
{ {
@ -59,18 +50,18 @@ namespace Emby.Naming.Video
} }
bool isStub = false; bool isStub = false;
string? container = null; ReadOnlySpan<char> container = ReadOnlySpan<char>.Empty;
string? stubType = null; string? stubType = null;
if (!isDirectory) if (!isDirectory)
{ {
var extension = Path.GetExtension(path); var extension = Path.GetExtension(path.AsSpan());
// Check supported extensions // Check supported extensions
if (!_options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) if (!namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
{ {
// It's not supported. Check stub extensions // It's not supported. Check stub extensions
if (!StubResolver.TryResolveFile(path, _options, out stubType)) if (!StubResolver.TryResolveFile(path, namingOptions, out stubType))
{ {
return null; return null;
} }
@ -81,33 +72,30 @@ namespace Emby.Naming.Video
container = extension.TrimStart('.'); container = extension.TrimStart('.');
} }
var flags = new FlagParser(_options).GetFlags(path); var format3DResult = Format3DParser.Parse(path, namingOptions);
var format3DResult = new Format3DParser(_options).Parse(flags);
var extraResult = new ExtraResolver(_options).GetExtraInfo(path); var extraResult = new ExtraResolver(namingOptions).GetExtraInfo(path);
var name = isDirectory var name = Path.GetFileNameWithoutExtension(path);
? Path.GetFileName(path)
: Path.GetFileNameWithoutExtension(path);
int? year = null; int? year = null;
if (parseName) if (parseName)
{ {
var cleanDateTimeResult = CleanDateTime(name); var cleanDateTimeResult = CleanDateTime(name, namingOptions);
name = cleanDateTimeResult.Name; name = cleanDateTimeResult.Name;
year = cleanDateTimeResult.Year; year = cleanDateTimeResult.Year;
if (extraResult.ExtraType == null if (extraResult.ExtraType == null
&& TryCleanString(name, out ReadOnlySpan<char> newName)) && TryCleanString(name, namingOptions, out var newName))
{ {
name = newName.ToString(); name = newName;
} }
} }
return new VideoFileInfo( return new VideoFileInfo(
path: path, path: path,
container: container, container: container.IsEmpty ? null : container.ToString(),
isStub: isStub, isStub: isStub,
name: name, name: name,
year: year, year: year,
@ -123,43 +111,47 @@ namespace Emby.Naming.Video
/// Determines if path is video file based on extension. /// Determines if path is video file based on extension.
/// </summary> /// </summary>
/// <param name="path">Path to file.</param> /// <param name="path">Path to file.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>True if is video file.</returns> /// <returns>True if is video file.</returns>
public bool IsVideoFile(string path) public static bool IsVideoFile(string path, NamingOptions namingOptions)
{ {
var extension = Path.GetExtension(path); var extension = Path.GetExtension(path.AsSpan());
return _options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); return namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
} }
/// <summary> /// <summary>
/// Determines if path is video file stub based on extension. /// Determines if path is video file stub based on extension.
/// </summary> /// </summary>
/// <param name="path">Path to file.</param> /// <param name="path">Path to file.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>True if is video file stub.</returns> /// <returns>True if is video file stub.</returns>
public bool IsStubFile(string path) public static bool IsStubFile(string path, NamingOptions namingOptions)
{ {
var extension = Path.GetExtension(path); var extension = Path.GetExtension(path.AsSpan());
return _options.StubFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); return namingOptions.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
} }
/// <summary> /// <summary>
/// Tries to clean name of clutter. /// Tries to clean name of clutter.
/// </summary> /// </summary>
/// <param name="name">Raw name.</param> /// <param name="name">Raw name.</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="newName">Clean name.</param> /// <param name="newName">Clean name.</param>
/// <returns>True if cleaning of name was successful.</returns> /// <returns>True if cleaning of name was successful.</returns>
public bool TryCleanString([NotNullWhen(true)] string? name, out ReadOnlySpan<char> newName) public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out string newName)
{ {
return CleanStringParser.TryClean(name, _options.CleanStringRegexes, out newName); return CleanStringParser.TryClean(name, namingOptions.CleanStringRegexes, out newName);
} }
/// <summary> /// <summary>
/// Tries to get name and year from raw name. /// Tries to get name and year from raw name.
/// </summary> /// </summary>
/// <param name="name">Raw name.</param> /// <param name="name">Raw name.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>Returns <see cref="CleanDateTimeResult"/> with name and optional year.</returns> /// <returns>Returns <see cref="CleanDateTimeResult"/> with name and optional year.</returns>
public CleanDateTimeResult CleanDateTime(string name) public static CleanDateTimeResult CleanDateTime(string name, NamingOptions namingOptions)
{ {
return CleanDateTimeParser.Clean(name, _options.CleanDateTimeRegexes); return CleanDateTimeParser.Clean(name, namingOptions.CleanDateTimeRegexes);
} }
} }
} }

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

@ -77,7 +77,6 @@ namespace Emby.Notifications
{ {
_libraryManager.ItemAdded += OnLibraryManagerItemAdded; _libraryManager.ItemAdded += OnLibraryManagerItemAdded;
_appHost.HasPendingRestartChanged += OnAppHostHasPendingRestartChanged; _appHost.HasPendingRestartChanged += OnAppHostHasPendingRestartChanged;
_appHost.HasUpdateAvailableChanged += OnAppHostHasUpdateAvailableChanged;
_activityManager.EntryCreated += OnActivityManagerEntryCreated; _activityManager.EntryCreated += OnActivityManagerEntryCreated;
return Task.CompletedTask; return Task.CompletedTask;
@ -132,25 +131,6 @@ namespace Emby.Notifications
return _config.GetConfiguration<NotificationOptions>("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) private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e)
{ {
if (!FilterItem(e.Item)) if (!FilterItem(e.Item))
@ -325,7 +305,6 @@ namespace Emby.Notifications
_libraryManager.ItemAdded -= OnLibraryManagerItemAdded; _libraryManager.ItemAdded -= OnLibraryManagerItemAdded;
_appHost.HasPendingRestartChanged -= OnAppHostHasPendingRestartChanged; _appHost.HasPendingRestartChanged -= OnAppHostHasPendingRestartChanged;
_appHost.HasUpdateAvailableChanged -= OnAppHostHasUpdateAvailableChanged;
_activityManager.EntryCreated -= OnActivityManagerEntryCreated; _activityManager.EntryCreated -= OnActivityManagerEntryCreated;
_disposed = true; _disposed = true;

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

@ -33,7 +33,7 @@ namespace Emby.Server.Implementations.AppBase
CachePath = cacheDirectoryPath; CachePath = cacheDirectoryPath;
WebPath = webDirectoryPath; WebPath = webDirectoryPath;
DataPath = Path.Combine(ProgramDataPath, "data"); _dataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName;
} }
/// <summary> /// <summary>
@ -55,11 +55,7 @@ namespace Emby.Server.Implementations.AppBase
/// Gets the folder path to the data directory. /// Gets the folder path to the data directory.
/// </summary> /// </summary>
/// <value>The data directory.</value> /// <value>The data directory.</value>
public string DataPath public string DataPath => _dataPath;
{
get => _dataPath;
private set => _dataPath = Directory.CreateDirectory(value).FullName;
}
/// <inheritdoc /> /// <inheritdoc />
public string VirtualDataPath => "%AppDataPath%"; public string VirtualDataPath => "%AppDataPath%";

@ -1,3 +1,5 @@
#nullable disable
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
@ -23,6 +25,11 @@ namespace Emby.Server.Implementations.AppBase
private readonly ConcurrentDictionary<string, object> _configurations = new ConcurrentDictionary<string, object>(); private readonly ConcurrentDictionary<string, object> _configurations = new ConcurrentDictionary<string, object>();
/// <summary>
/// The _configuration sync lock.
/// </summary>
private readonly object _configurationSyncLock = new object();
private ConfigurationStore[] _configurationStores = Array.Empty<ConfigurationStore>(); private ConfigurationStore[] _configurationStores = Array.Empty<ConfigurationStore>();
private IConfigurationFactory[] _configurationFactories = Array.Empty<IConfigurationFactory>(); private IConfigurationFactory[] _configurationFactories = Array.Empty<IConfigurationFactory>();
@ -31,11 +38,6 @@ namespace Emby.Server.Implementations.AppBase
/// </summary> /// </summary>
private bool _configurationLoaded; private bool _configurationLoaded;
/// <summary>
/// The _configuration sync lock.
/// </summary>
private readonly object _configurationSyncLock = new object();
/// <summary> /// <summary>
/// The _configuration. /// The _configuration.
/// </summary> /// </summary>
@ -297,25 +299,29 @@ namespace Emby.Server.Implementations.AppBase
/// <inheritdoc /> /// <inheritdoc />
public object GetConfiguration(string key) public object GetConfiguration(string key)
{ {
return _configurations.GetOrAdd(key, k => return _configurations.GetOrAdd(
{ key,
var file = GetConfigurationFile(key); (k, configurationManager) =>
{
var file = configurationManager.GetConfigurationFile(k);
var configurationInfo = _configurationStores var configurationInfo = Array.Find(
.FirstOrDefault(i => string.Equals(i.Key, key, StringComparison.OrdinalIgnoreCase)); configurationManager._configurationStores,
i => string.Equals(i.Key, k, StringComparison.OrdinalIgnoreCase));
if (configurationInfo == null) if (configurationInfo == null)
{ {
throw new ResourceNotFoundException("Configuration with key " + key + " not found."); throw new ResourceNotFoundException("Configuration with key " + k + " not found.");
} }
var configurationType = configurationInfo.ConfigurationType; var configurationType = configurationInfo.ConfigurationType;
lock (_configurationSyncLock) lock (configurationManager._configurationSyncLock)
{ {
return LoadConfiguration(file, configurationType); return configurationManager.LoadConfiguration(file, configurationType);
} }
}); },
this);
} }
private object LoadConfiguration(string path, Type configurationType) private object LoadConfiguration(string path, Type configurationType)

@ -1,5 +1,3 @@
#nullable enable
using System; using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@ -35,27 +33,27 @@ namespace Emby.Server.Implementations.AppBase
} }
catch (Exception) catch (Exception)
{ {
configuration = Activator.CreateInstance(type) ?? throw new ArgumentException($"Provided path ({type}) is not valid.", nameof(type)); // Note: CreateInstance returns null for Nullable<T>, e.g. CreateInstance(typeof(int?)) returns null.
configuration = Activator.CreateInstance(type)!;
} }
using var stream = new MemoryStream(buffer?.Length ?? 0); using var stream = new MemoryStream(buffer?.Length ?? 0);
xmlSerializer.SerializeToStream(configuration, stream); xmlSerializer.SerializeToStream(configuration, stream);
// Take the object we just got and serialize it back to bytes // Take the object we just got and serialize it back to bytes
byte[] newBytes = stream.GetBuffer(); Span<byte> newBytes = stream.GetBuffer().AsSpan(0, (int)stream.Length);
int newBytesLen = (int)stream.Length;
// If the file didn't exist before, or if something has changed, re-save // 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)); var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path));
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
// Save it after load in case we got new items // 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)) using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
{ {
fs.Write(newBytes, 0, newBytesLen); fs.Write(newBytes);
} }
} }

File diff suppressed because it is too large Load Diff

@ -45,6 +45,7 @@ namespace Emby.Server.Implementations.Archiving
options.Overwrite = true; options.Overwrite = true;
} }
Directory.CreateDirectory(targetPath);
reader.WriteAllToDirectory(targetPath, options); reader.WriteAllToDirectory(targetPath, options);
} }
@ -58,6 +59,7 @@ namespace Emby.Server.Implementations.Archiving
Overwrite = overwriteExistingFiles Overwrite = overwriteExistingFiles
}; };
Directory.CreateDirectory(targetPath);
reader.WriteAllToDirectory(targetPath, options); reader.WriteAllToDirectory(targetPath, options);
} }
@ -71,6 +73,7 @@ namespace Emby.Server.Implementations.Archiving
Overwrite = overwriteExistingFiles Overwrite = overwriteExistingFiles
}; };
Directory.CreateDirectory(targetPath);
reader.WriteAllToDirectory(targetPath, options); reader.WriteAllToDirectory(targetPath, options);
} }
@ -120,6 +123,7 @@ namespace Emby.Server.Implementations.Archiving
Overwrite = overwriteExistingFiles Overwrite = overwriteExistingFiles
}; };
Directory.CreateDirectory(targetPath);
reader.WriteAllToDirectory(targetPath, options); reader.WriteAllToDirectory(targetPath, options);
} }
@ -151,6 +155,7 @@ namespace Emby.Server.Implementations.Archiving
Overwrite = overwriteExistingFiles Overwrite = overwriteExistingFiles
}; };
Directory.CreateDirectory(targetPath);
reader.WriteAllToDirectory(targetPath, options); reader.WriteAllToDirectory(targetPath, options);
} }
} }

@ -1,3 +1,5 @@
#nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
@ -8,8 +10,8 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Entities; using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Json;
using MediaBrowser.Common.Progress; using MediaBrowser.Common.Progress;
using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
@ -100,7 +102,7 @@ namespace Emby.Server.Implementations.Channels
var internalChannel = _libraryManager.GetItemById(item.ChannelId); var internalChannel = _libraryManager.GetItemById(item.ChannelId);
var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id));
return !(channel is IDisableMediaSourceDisplay); return channel is not IDisableMediaSourceDisplay;
} }
/// <inheritdoc /> /// <inheritdoc />
@ -584,7 +586,7 @@ namespace Emby.Server.Implementations.Channels
{ {
var supportsLatest = provider is ISupportsLatestMedia; var supportsLatest = provider is ISupportsLatestMedia;
return new ChannelFeatures return new ChannelFeatures(channel.Name, channel.Id)
{ {
CanFilter = !features.MaxPageSize.HasValue, CanFilter = !features.MaxPageSize.HasValue,
CanSearch = provider is ISearchableChannel, CanSearch = provider is ISearchableChannel,
@ -594,8 +596,6 @@ namespace Emby.Server.Implementations.Channels
MediaTypes = features.MediaTypes.ToArray(), MediaTypes = features.MediaTypes.ToArray(),
SupportsSortOrderToggle = features.SupportsSortOrderToggle, SupportsSortOrderToggle = features.SupportsSortOrderToggle,
SupportsLatestMedia = supportsLatest, SupportsLatestMedia = supportsLatest,
Name = channel.Name,
Id = channel.Id.ToString("N", CultureInfo.InvariantCulture),
SupportsContentDownloading = features.SupportsContentDownloading, SupportsContentDownloading = features.SupportsContentDownloading,
AutoRefreshLevels = features.AutoRefreshLevels AutoRefreshLevels = features.AutoRefreshLevels
}; };
@ -813,7 +813,7 @@ namespace Emby.Server.Implementations.Channels
{ {
if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) 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); var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (cachedResult != null) if (cachedResult != null)
{ {
@ -836,7 +836,7 @@ namespace Emby.Server.Implementations.Channels
{ {
if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) 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); var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (cachedResult != null) if (cachedResult != null)
{ {
@ -878,7 +878,7 @@ namespace Emby.Server.Implementations.Channels
} }
} }
private async Task CacheResponse(object result, string path) private async Task CacheResponse(ChannelItemResult result, string path)
{ {
try try
{ {
@ -1075,14 +1075,6 @@ namespace Emby.Server.Implementations.Channels
forceUpdate = true; 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)) if (!internalChannelId.Equals(item.ChannelId))
{ {
forceUpdate = true; forceUpdate = true;

@ -82,9 +82,9 @@ namespace Emby.Server.Implementations.Collections
return null; return null;
}) })
.Where(i => i != null) .Where(i => i != null)
.GroupBy(x => x.Id) .GroupBy(x => x!.Id) // We removed the null values
.Select(x => x.First()) .Select(x => x.First())
.ToList(); .ToList()!; // Again... the list doesn't contain any null values
} }
/// <inheritdoc /> /// <inheritdoc />

@ -61,13 +61,13 @@ namespace Emby.Server.Implementations.Collections
} }
/// <inheritdoc /> /// <inheritdoc />
public event EventHandler<CollectionCreatedEventArgs> CollectionCreated; public event EventHandler<CollectionCreatedEventArgs>? CollectionCreated;
/// <inheritdoc /> /// <inheritdoc />
public event EventHandler<CollectionModifiedEventArgs> ItemsAddedToCollection; public event EventHandler<CollectionModifiedEventArgs>? ItemsAddedToCollection;
/// <inheritdoc /> /// <inheritdoc />
public event EventHandler<CollectionModifiedEventArgs> ItemsRemovedFromCollection; public event EventHandler<CollectionModifiedEventArgs>? ItemsRemovedFromCollection;
private IEnumerable<Folder> FindFolders(string path) private IEnumerable<Folder> FindFolders(string path)
{ {
@ -78,14 +78,12 @@ namespace Emby.Server.Implementations.Collections
.Where(i => _fileSystem.AreEqual(path, i.Path) || _fileSystem.ContainsSubPath(i.Path, path)); .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) var existingFolder = FindFolders(path).FirstOrDefault();
.ToList(); if (existingFolder != null)
if (existingFolders.Count > 0)
{ {
return existingFolders[0]; return existingFolder;
} }
if (!createIfNeeded) if (!createIfNeeded)
@ -97,7 +95,7 @@ namespace Emby.Server.Implementations.Collections
var libraryOptions = new LibraryOptions var libraryOptions = new LibraryOptions
{ {
PathInfos = new[] { new MediaPathInfo { Path = path } }, PathInfos = new[] { new MediaPathInfo(path) },
EnableRealtimeMonitor = false, EnableRealtimeMonitor = false,
SaveLocalMetadata = true SaveLocalMetadata = true
}; };
@ -114,7 +112,7 @@ namespace Emby.Server.Implementations.Collections
return Path.Combine(_appPaths.DataPath, "collections"); return Path.Combine(_appPaths.DataPath, "collections");
} }
private Task<Folder> GetCollectionsFolder(bool createIfNeeded) private Task<Folder?> GetCollectionsFolder(bool createIfNeeded)
{ {
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
} }
@ -162,9 +160,9 @@ namespace Emby.Server.Implementations.Collections
DateCreated = DateTime.UtcNow DateCreated = DateTime.UtcNow
}; };
parentFolder.AddChild(collection, CancellationToken.None); parentFolder.AddChild(collection);
if (options.ItemIdList.Length > 0) if (options.ItemIdList.Count > 0)
{ {
await AddToCollectionAsync( await AddToCollectionAsync(
collection.Id, collection.Id,
@ -198,13 +196,12 @@ namespace Emby.Server.Implementations.Collections
} }
/// <inheritdoc /> /// <inheritdoc />
public Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> ids) public Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds)
=> AddToCollectionAsync(collectionId, ids, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem))); => AddToCollectionAsync(collectionId, itemIds, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem)));
private async Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> ids, bool fireEvent, MetadataRefreshOptions refreshOptions) private async Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> ids, bool fireEvent, MetadataRefreshOptions refreshOptions)
{ {
var collection = _libraryManager.GetItemById(collectionId) as BoxSet; if (_libraryManager.GetItemById(collectionId) is not BoxSet collection)
if (collection == null)
{ {
throw new ArgumentException("No collection exists with the supplied Id"); throw new ArgumentException("No collection exists with the supplied Id");
} }
@ -248,11 +245,7 @@ namespace Emby.Server.Implementations.Collections
if (fireEvent) if (fireEvent)
{ {
ItemsAddedToCollection?.Invoke(this, new CollectionModifiedEventArgs ItemsAddedToCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList));
{
Collection = collection,
ItemsChanged = itemList
});
} }
} }
} }
@ -260,9 +253,7 @@ namespace Emby.Server.Implementations.Collections
/// <inheritdoc /> /// <inheritdoc />
public async Task RemoveFromCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds) public async Task RemoveFromCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds)
{ {
var collection = _libraryManager.GetItemById(collectionId) as BoxSet; if (_libraryManager.GetItemById(collectionId) is not BoxSet collection)
if (collection == null)
{ {
throw new ArgumentException("No collection exists with the supplied Id"); throw new ArgumentException("No collection exists with the supplied Id");
} }
@ -304,11 +295,7 @@ namespace Emby.Server.Implementations.Collections
}, },
RefreshPriority.High); RefreshPriority.High);
ItemsRemovedFromCollection?.Invoke(this, new CollectionModifiedEventArgs ItemsRemovedFromCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList));
{
Collection = collection,
ItemsChanged = itemList
});
} }
/// <inheritdoc /> /// <inheritdoc />
@ -320,11 +307,7 @@ namespace Emby.Server.Implementations.Collections
foreach (var item in items) foreach (var item in items)
{ {
if (item is not ISupportsBoxSetGrouping) if (item is ISupportsBoxSetGrouping)
{
results[item.Id] = item;
}
else
{ {
var itemId = item.Id; var itemId = item.Id;
@ -348,6 +331,7 @@ namespace Emby.Server.Implementations.Collections
} }
var alreadyInResults = false; var alreadyInResults = false;
// this is kind of a performance hack because only Video has alternate versions that should be in a box set? // 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) if (item is Video video)
{ {
@ -363,11 +347,13 @@ namespace Emby.Server.Implementations.Collections
} }
} }
if (!alreadyInResults) if (alreadyInResults)
{ {
results[itemId] = item; continue;
} }
} }
results[item.Id] = item;
} }
return results.Values; return results.Values;

@ -1,3 +1,5 @@
#nullable disable
using System; using System;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;

@ -1,19 +1,20 @@
#nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Cryptography;
using static MediaBrowser.Common.Cryptography.Constants; using static MediaBrowser.Model.Cryptography.Constants;
namespace Emby.Server.Implementations.Cryptography namespace Emby.Server.Implementations.Cryptography
{ {
/// <summary> /// <summary>
/// Class providing abstractions over cryptographic functions. /// Class providing abstractions over cryptographic functions.
/// </summary> /// </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>() private static readonly HashSet<string> _supportedHashMethods = new HashSet<string>()
{ {
"MD5", "MD5",
@ -32,71 +33,71 @@ namespace Emby.Server.Implementations.Cryptography
"System.Security.Cryptography.SHA512" "System.Security.Cryptography.SHA512"
}; };
private RandomNumberGenerator _randomNumberGenerator; /// <inheritdoc />
public string DefaultHashMethod => "PBKDF2-SHA512";
private bool _disposed;
/// <summary> /// <inheritdoc />
/// Initializes a new instance of the <see cref="CryptographyProvider"/> class. public PasswordHash CreatePasswordHash(ReadOnlySpan<char> password)
/// </summary>
public CryptographyProvider()
{ {
// FIXME: When we get DotNet Standard 2.1 we need to revisit how we do the crypto byte[] salt = GenerateSalt();
// Currently supported hash methods from https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.cryptoconfig?view=netcore-2.1 return new PasswordHash(
// there might be a better way to autogenerate this list as dotnet updates, but I couldn't find one DefaultHashMethod,
// 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 Rfc2898DeriveBytes.Pbkdf2(
_randomNumberGenerator = RandomNumberGenerator.Create(); password,
salt,
DefaultIterations,
HashAlgorithmName.SHA512,
DefaultOutputLength),
salt,
new Dictionary<string, string>
{
{ "iterations", DefaultIterations.ToString(CultureInfo.InvariantCulture) }
});
} }
/// <inheritdoc /> /// <inheritdoc />
public string DefaultHashMethod => "PBKDF2"; public bool Verify(PasswordHash hash, ReadOnlySpan<char> password)
/// <inheritdoc />
public IEnumerable<string> GetSupportedHashMethods()
=> _supportedHashMethods;
private byte[] PBKDF2(string method, byte[] bytes, byte[] salt, int iterations)
{ {
// downgrading for now as we need this library to be dotnetstandard compliant if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
// with this downgrade we'll add a check to make sure we're on the downgrade method at the moment
if (method != DefaultHashMethod)
{ {
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); if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
return r.GetBytes(32);
}
/// <inheritdoc />
public byte[] ComputeHash(string hashMethod, byte[] bytes, byte[] salt)
{
if (hashMethod == DefaultHashMethod)
{ {
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}."); using var h = HashAlgorithm.Create(hash.Id) ?? throw new ResourceNotFoundException($"Unknown hash method: {hash.Id}.");
if (salt.Length == 0) 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(bytes, salted, bytes.Length);
Array.Copy(salt, 0, salted, bytes.Length, salt.Length); hash.Salt.CopyTo(salted.AsSpan(bytes.Length));
return h.ComputeHash(salted); return hash.Hash.SequenceEqual(h.ComputeHash(salted));
} }
/// <inheritdoc />
public byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt)
=> PBKDF2(DefaultHashMethod, bytes, salt, DefaultIterations);
/// <inheritdoc /> /// <inheritdoc />
public byte[] GenerateSalt() public byte[] GenerateSalt()
=> GenerateSalt(DefaultSaltLength); => GenerateSalt(DefaultSaltLength);
@ -104,35 +105,10 @@ namespace Emby.Server.Implementations.Cryptography
/// <inheritdoc /> /// <inheritdoc />
public byte[] GenerateSalt(int length) public byte[] GenerateSalt(int length)
{ {
byte[] salt = new byte[length]; var salt = new byte[length];
_randomNumberGenerator.GetBytes(salt); using var rng = RandomNumberGenerator.Create();
rng.GetNonZeroBytes(salt);
return 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;
}
} }
} }

@ -1,3 +1,5 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@ -59,7 +61,7 @@ namespace Emby.Server.Implementations.Data
protected virtual int? CacheSize => null; protected virtual int? CacheSize => null;
/// <summary> /// <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> /// </summary>
/// <value>The journal mode.</value> /// <value>The journal mode.</value>
protected virtual string JournalMode => "TRUNCATE"; protected virtual string JournalMode => "TRUNCATE";
@ -96,7 +98,7 @@ namespace Emby.Server.Implementations.Data
/// <value>The write connection.</value> /// <value>The write connection.</value>
protected SQLiteDatabaseConnection WriteConnection { get; set; } protected SQLiteDatabaseConnection WriteConnection { get; set; }
protected ManagedConnection GetConnection(bool _ = false) protected ManagedConnection GetConnection(bool readOnly = false)
{ {
WriteLock.Wait(); WriteLock.Wait();
if (WriteConnection != null) if (WriteConnection != null)
@ -181,11 +183,9 @@ namespace Emby.Server.Implementations.Data
foreach (var row in connection.Query("PRAGMA table_info(" + table + ")")) foreach (var row in connection.Query("PRAGMA table_info(" + table + ")"))
{ {
if (row[1].SQLiteType != SQLiteType.Null) if (row.TryGetString(1, out var columnName))
{ {
var name = row[1].ToString(); columnNames.Add(columnName);
columnNames.Add(name);
} }
} }
@ -249,55 +249,4 @@ namespace Emby.Server.Implementations.Data
_disposed = true; _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
}
} }

@ -7,10 +7,12 @@ using SQLitePCL.pretty;
namespace Emby.Server.Implementations.Data namespace Emby.Server.Implementations.Data
{ {
public class ManagedConnection : IDisposable public sealed class ManagedConnection : IDisposable
{ {
private SQLiteDatabaseConnection _db;
private readonly SemaphoreSlim _writeLock; private readonly SemaphoreSlim _writeLock;
private SQLiteDatabaseConnection? _db;
private bool _disposed = false; private bool _disposed = false;
public ManagedConnection(SQLiteDatabaseConnection db, SemaphoreSlim writeLock) public ManagedConnection(SQLiteDatabaseConnection db, SemaphoreSlim writeLock)
@ -54,12 +56,12 @@ namespace Emby.Server.Implementations.Data
return _db.RunInTransaction(action, mode); return _db.RunInTransaction(action, mode);
} }
public IEnumerable<IReadOnlyList<IResultSetValue>> Query(string sql) public IEnumerable<IReadOnlyList<ResultSetValue>> Query(string sql)
{ {
return _db.Query(sql); return _db.Query(sql);
} }
public IEnumerable<IReadOnlyList<IResultSetValue>> Query(string sql, params object[] values) public IEnumerable<IReadOnlyList<ResultSetValue>> Query(string sql, params object[] values)
{ {
return _db.Query(sql, values); return _db.Query(sql, values);
} }

@ -1,3 +1,4 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@ -64,7 +65,7 @@ namespace Emby.Server.Implementations.Data
}); });
} }
public static Guid ReadGuidFromBlob(this IResultSetValue result) public static Guid ReadGuidFromBlob(this ResultSetValue result)
{ {
return new Guid(result.ToBlob()); return new Guid(result.ToBlob());
} }
@ -85,7 +86,7 @@ namespace Emby.Server.Implementations.Data
private static string GetDateTimeKindFormat(DateTimeKind kind) private static string GetDateTimeKindFormat(DateTimeKind kind)
=> (kind == DateTimeKind.Utc) ? DatetimeFormatUtc : DatetimeFormatLocal; => (kind == DateTimeKind.Utc) ? DatetimeFormatUtc : DatetimeFormatLocal;
public static DateTime ReadDateTime(this IResultSetValue result) public static DateTime ReadDateTime(this ResultSetValue result)
{ {
var dateText = result.ToString(); var dateText = result.ToString();
@ -93,52 +94,142 @@ namespace Emby.Server.Implementations.Data
dateText, dateText,
_datetimeFormats, _datetimeFormats,
DateTimeFormatInfo.InvariantInfo, DateTimeFormatInfo.InvariantInfo,
DateTimeStyles.None).ToUniversalTime(); DateTimeStyles.AdjustToUniversal);
} }
public static DateTime? TryReadDateTime(this IResultSetValue result) public static bool TryReadDateTime(this IReadOnlyList<ResultSetValue> reader, int index, out DateTime result)
{ {
var dateText = result.ToString(); var item = reader[index];
if (item.IsDbNull())
{
result = default;
return false;
}
var dateText = item.ToString();
if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AdjustToUniversal, out var dateTimeResult))
{
result = dateTimeResult;
return true;
}
if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.None, out var dateTimeResult)) result = default;
return false;
}
public static bool TryGetGuid(this IReadOnlyList<ResultSetValue> reader, int index, out Guid result)
{
var item = reader[index];
if (item.IsDbNull())
{ {
return dateTimeResult.ToUniversalTime(); result = default;
return false;
} }
return null; result = item.ReadGuidFromBlob();
return true;
} }
public static bool IsDBNull(this IReadOnlyList<IResultSetValue> result, int index) public static bool IsDbNull(this ResultSetValue result)
{ {
return result[index].SQLiteType == SQLiteType.Null; return result.SQLiteType == SQLiteType.Null;
} }
public static string GetString(this IReadOnlyList<IResultSetValue> result, int index) public static string GetString(this IReadOnlyList<ResultSetValue> result, int index)
{ {
return result[index].ToString(); return result[index].ToString();
} }
public static bool GetBoolean(this IReadOnlyList<IResultSetValue> result, int index) public static bool TryGetString(this IReadOnlyList<ResultSetValue> reader, int index, out string result)
{
result = null;
var item = reader[index];
if (item.IsDbNull())
{
return false;
}
result = item.ToString();
return true;
}
public static bool GetBoolean(this IReadOnlyList<ResultSetValue> result, int index)
{ {
return result[index].ToBool(); return result[index].ToBool();
} }
public static int GetInt32(this IReadOnlyList<IResultSetValue> result, int index) public static bool TryGetBoolean(this IReadOnlyList<ResultSetValue> reader, int index, out bool result)
{
var item = reader[index];
if (item.IsDbNull())
{
result = default;
return false;
}
result = item.ToBool();
return true;
}
public static bool TryGetInt32(this IReadOnlyList<ResultSetValue> reader, int index, out int result)
{ {
return result[index].ToInt(); var item = reader[index];
if (item.IsDbNull())
{
result = default;
return false;
}
result = item.ToInt();
return true;
} }
public static long GetInt64(this IReadOnlyList<IResultSetValue> result, int index) public static long GetInt64(this IReadOnlyList<ResultSetValue> result, int index)
{ {
return result[index].ToInt64(); return result[index].ToInt64();
} }
public static float GetFloat(this IReadOnlyList<IResultSetValue> result, int index) public static bool TryGetInt64(this IReadOnlyList<ResultSetValue> reader, int index, out long result)
{
var item = reader[index];
if (item.IsDbNull())
{
result = default;
return false;
}
result = item.ToInt64();
return true;
}
public static bool TryGetSingle(this IReadOnlyList<ResultSetValue> reader, int index, out float result)
{
var item = reader[index];
if (item.IsDbNull())
{
result = default;
return false;
}
result = item.ToFloat();
return true;
}
public static bool TryGetDouble(this IReadOnlyList<ResultSetValue> reader, int index, out double result)
{ {
return result[index].ToFloat(); var item = reader[index];
if (item.IsDbNull())
{
result = default;
return false;
}
result = item.ToDouble();
return true;
} }
public static Guid GetGuid(this IReadOnlyList<IResultSetValue> result, int index) public static Guid GetGuid(this IReadOnlyList<ResultSetValue> result, int index)
{ {
return result[index].ReadGuidFromBlob(); return result[index].ReadGuidFromBlob();
} }
@ -350,7 +441,7 @@ namespace Emby.Server.Implementations.Data
} }
} }
public static IEnumerable<IReadOnlyList<IResultSetValue>> ExecuteQuery(this IStatement statement) public static IEnumerable<IReadOnlyList<ResultSetValue>> ExecuteQuery(this IStatement statement)
{ {
while (statement.MoveNext()) while (statement.MoveNext())
{ {

File diff suppressed because it is too large Load Diff

@ -1,3 +1,5 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@ -30,6 +32,9 @@ namespace Emby.Server.Implementations.Data
/// <summary> /// <summary>
/// Opens the connection to the database. /// Opens the connection to the database.
/// </summary> /// </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) public void Initialize(IUserManager userManager, SemaphoreSlim dbLock, SQLiteDatabaseConnection dbConnection)
{ {
WriteLock.Dispose(); WriteLock.Dispose();
@ -47,8 +52,8 @@ namespace Emby.Server.Implementations.Data
connection.RunInTransaction( connection.RunInTransaction(
db => 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)", "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", "drop index if exists idx_userdata",
@ -127,19 +132,17 @@ namespace Emby.Server.Implementations.Data
return list; return list;
} }
/// <summary> /// <inheritdoc />
/// Saves the user data. public void SaveUserData(long userId, string key, UserItemData userData, CancellationToken cancellationToken)
/// </summary>
public void SaveUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken)
{ {
if (userData == null) if (userData == null)
{ {
throw new ArgumentNullException(nameof(userData)); 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)) if (string.IsNullOrEmpty(key))
@ -147,22 +150,23 @@ namespace Emby.Server.Implementations.Data
throw new ArgumentNullException(nameof(key)); 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) if (userData == null)
{ {
throw new ArgumentNullException(nameof(userData)); 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> /// <summary>
@ -172,7 +176,6 @@ namespace Emby.Server.Implementations.Data
/// <param name="key">The key.</param> /// <param name="key">The key.</param>
/// <param name="userData">The user data.</param> /// <param name="userData">The user data.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
public void PersistUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken) public void PersistUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@ -262,19 +265,19 @@ namespace Emby.Server.Implementations.Data
/// <summary> /// <summary>
/// Gets the user data. /// Gets the user data.
/// </summary> /// </summary>
/// <param name="internalUserId">The user id.</param> /// <param name="userId">The user id.</param>
/// <param name="key">The key.</param> /// <param name="key">The key.</param>
/// <returns>Task{UserItemData}.</returns> /// <returns>Task{UserItemData}.</returns>
/// <exception cref="ArgumentNullException"> /// <exception cref="ArgumentNullException">
/// userId /// userId
/// or /// or
/// key /// key.
/// </exception> /// </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)) if (string.IsNullOrEmpty(key))
@ -286,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")) 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); statement.TryBind("@Key", key);
foreach (var row in statement.ExecuteQuery()) foreach (var row in statement.ExecuteQuery())
@ -299,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) if (keys == null)
{ {
@ -311,19 +314,19 @@ namespace Emby.Server.Implementations.Data
return null; return null;
} }
return GetUserData(internalUserId, keys[0]); return GetUserData(userId, keys[0]);
} }
/// <summary> /// <summary>
/// Return all user-data associated with the given user. /// Return all user-data associated with the given user.
/// </summary> /// </summary>
/// <param name="internalUserId"></param> /// <param name="userId">The internal user id.</param>
/// <returns></returns> /// <returns>The list of user item data.</returns>
public List<UserItemData> GetAllUserData(long internalUserId) 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>(); var list = new List<UserItemData>();
@ -332,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")) 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()) foreach (var row in statement.ExecuteQuery())
{ {
@ -347,17 +350,18 @@ namespace Emby.Server.Implementations.Data
/// <summary> /// <summary>
/// Read a row from the specified reader into the provided userData object. /// Read a row from the specified reader into the provided userData object.
/// </summary> /// </summary>
/// <param name="reader"></param> /// <param name="reader">The list of result set values.</param>
private UserItemData ReadRow(IReadOnlyList<IResultSetValue> reader) /// <returns>The user item data.</returns>
private UserItemData ReadRow(IReadOnlyList<ResultSetValue> reader)
{ {
var userData = new UserItemData(); var userData = new UserItemData();
userData.Key = reader[0].ToString(); userData.Key = reader[0].ToString();
// userData.UserId = reader[1].ReadGuidFromBlob(); // userData.UserId = reader[1].ReadGuidFromBlob();
if (reader[2].SQLiteType != SQLiteType.Null) if (reader.TryGetDouble(2, out var rating))
{ {
userData.Rating = reader[2].ToDouble(); userData.Rating = rating;
} }
userData.Played = reader[3].ToBool(); userData.Played = reader[3].ToBool();
@ -365,19 +369,19 @@ namespace Emby.Server.Implementations.Data
userData.IsFavorite = reader[5].ToBool(); userData.IsFavorite = reader[5].ToBool();
userData.PlaybackPositionTicks = reader[6].ToInt64(); userData.PlaybackPositionTicks = reader[6].ToInt64();
if (reader[7].SQLiteType != SQLiteType.Null) if (reader.TryReadDateTime(7, out var lastPlayedDate))
{ {
userData.LastPlayedDate = reader[7].TryReadDateTime(); userData.LastPlayedDate = lastPlayedDate;
} }
if (reader[8].SQLiteType != SQLiteType.Null) if (reader.TryGetInt32(8, out var audioStreamIndex))
{ {
userData.AudioStreamIndex = reader[8].ToInt(); userData.AudioStreamIndex = audioStreamIndex;
} }
if (reader[9].SQLiteType != SQLiteType.Null) if (reader.TryGetInt32(9, out var subtitleStreamIndex))
{ {
userData.SubtitleStreamIndex = reader[9].ToInt(); userData.SubtitleStreamIndex = subtitleStreamIndex;
} }
return userData; return userData;

@ -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
}

@ -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
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save