Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fbd873929 | |||
| 39958ad9e5 | |||
| 7bde1ac224 | |||
| 143aee7e9e | |||
| 8c65dfefa1 | |||
| 869d8d3abc | |||
| 8d0534195d | |||
| 36af7fa7bf | |||
| 4b4b4cd94d | |||
| f2b358a3c8 | |||
| 95600752a3 | |||
| 7249b5744c | |||
| d8b0034a50 | |||
| ed379a1882 | |||
| 984e67c067 | |||
| eece62a90b | |||
| e5c34e7096 | |||
| 9526083523 | |||
| c4fb0285fc | |||
| 620b7a2495 | |||
| ac9aa273ab | |||
| 72360ba292 | |||
| 706000cfce | |||
| 734145ab98 | |||
| 89d32a9525 | |||
| 19a35a6159 | |||
| 8743c22551 | |||
| d1ba366f97 | |||
| 999de06d6b | |||
| 6435600a9c | |||
| e52e448c30 | |||
| f1137a9587 | |||
| 6de99306ec | |||
| 03ff69a6e1 | |||
| 94d0f7b1ac | |||
| e83a7e62f2 | |||
| 445c6c9448 | |||
| 5f3189af41 | |||
| b278dcf475 | |||
| f7d80ae9e6 | |||
| a023b9c88d | |||
| 40f35f6094 | |||
| 2b6fc19842 | |||
| 8c29098c8a | |||
| 758ee0af76 | |||
| 2e19c247ef | |||
| 511f90d6d3 | |||
| 1ae45519d0 | |||
| 586fa01e46 | |||
| 2ac0edc052 | |||
| b37ebec5f6 | |||
| 938c043596 | |||
| 46a53d0605 | |||
| 97f88743b8 | |||
| 2c62d40f0d | |||
| dca3cc74b7 | |||
| be095f85ab | |||
| f51c63e244 | |||
| cc678383c9 | |||
| ba0720a555 | |||
| 417df3df57 | |||
| 169e48ac00 | |||
| 01ae62aa49 | |||
| d2df6adc16 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.11",
|
||||
"version": "9.0.11",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
{
|
||||
"name": "Development Jellyfin Server",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:10.0-noble",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
// restores nuget packages, installs the dotnet workloads and installs the dev https certificate
|
||||
"postStartCommand": "sudo dotnet restore; sudo dotnet workload update; sudo dotnet dev-certs https --trust; sudo bash \"./.devcontainer/install-ffmpeg.sh\"",
|
||||
// The previous way of installing extensions via the vs command dont work on selfhosted devcontainers
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-dotnettools.csharp",
|
||||
"editorconfig.editorconfig",
|
||||
"github.vscode-github-actions",
|
||||
"ms-dotnettools.vscode-dotnet-runtime",
|
||||
"ms-dotnettools.csdevkit",
|
||||
"alexcvzz.vscode-sqlite",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"eamodio.gitlens",
|
||||
"redhat.vscode-xml"
|
||||
]
|
||||
}
|
||||
},
|
||||
// reads the extensions list and installs them
|
||||
"postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/dotnet:2": {
|
||||
"version": "none",
|
||||
"dotnetRuntimeVersions": "10.0",
|
||||
"aspNetCoreRuntimeVersions": "10.0"
|
||||
"dotnetRuntimeVersions": "9.0",
|
||||
"aspNetCoreRuntimeVersions": "9.0"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
|
||||
"preserve_apt_list": false,
|
||||
@@ -33,7 +19,7 @@
|
||||
"libfontconfig1"
|
||||
]
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:3": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"dockerDashComposeVersion": "v2"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
|
||||
@@ -15,7 +15,7 @@ sudo apt-get install software-properties-common -y
|
||||
sudo add-apt-repository universe -y
|
||||
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/jellyfin.gpg
|
||||
curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg
|
||||
export VERSION_OS="$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release )"
|
||||
export VERSION_CODENAME="$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )"
|
||||
export DPKG_ARCHITECTURE="$( dpkg --print-architecture )"
|
||||
|
||||
@@ -379,9 +379,6 @@ dotnet_diagnostic.CA1720.severity = suggestion
|
||||
# disable warning CA1724: Type names should not match namespaces
|
||||
dotnet_diagnostic.CA1724.severity = suggestion
|
||||
|
||||
# disable warning CA1873: Avoid potentially expensive logging
|
||||
dotnet_diagnostic.CA1873.severity = suggestion
|
||||
|
||||
# disable warning CA1805: Do not initialize unnecessarily
|
||||
dotnet_diagnostic.CA1805.severity = suggestion
|
||||
|
||||
@@ -403,10 +400,6 @@ dotnet_diagnostic.CA1861.severity = suggestion
|
||||
# disable warning CA2000: Dispose objects before losing scope
|
||||
dotnet_diagnostic.CA2000.severity = suggestion
|
||||
|
||||
# TODO: Reevaluate when false positives are fixed: https://github.com/dotnet/roslyn-analyzers/issues/7699
|
||||
# disable warning CA2025: Do not pass 'IDisposable' instances into unawaited tasks
|
||||
dotnet_diagnostic.CA2025.severity = suggestion
|
||||
|
||||
# disable warning CA2253: Named placeholders should not be numeric values
|
||||
dotnet_diagnostic.CA2253.severity = suggestion
|
||||
|
||||
|
||||
+4
-11
@@ -1,11 +1,4 @@
|
||||
# Joshua must review all changes to bump_version and any files it touches
|
||||
bump_version @joshuaboniface
|
||||
.github/ISSUE_TEMPLATE @joshuaboniface
|
||||
MediaBrowser.Common/MediaBrowser.Common.csproj @joshuaboniface
|
||||
Jellyfin.Data/Jellyfin.Data.csproj @joshuaboniface
|
||||
MediaBrowser.Controller/MediaBrowser.Controller.csproj @joshuaboniface
|
||||
MediaBrowser.Model/MediaBrowser.Model.csproj @joshuaboniface
|
||||
Emby.Naming/Emby.Naming.csproj @joshuaboniface
|
||||
src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @joshuaboniface
|
||||
# Core must approve all changes within the repo config
|
||||
.github/ @jellyfin/core
|
||||
# Joshua must review all changes to deployment and build.sh
|
||||
.ci/* @joshuaboniface
|
||||
deployment/* @joshuaboniface
|
||||
build.sh @joshuaboniface
|
||||
|
||||
@@ -87,12 +87,7 @@ body:
|
||||
label: Jellyfin Server version
|
||||
description: What version of Jellyfin are you using?
|
||||
options:
|
||||
- 10.11.11
|
||||
- 10.11.10
|
||||
- 10.11.9
|
||||
- 10.11.8
|
||||
- 10.11.7
|
||||
- 10.11.6
|
||||
- 10.10.0+
|
||||
- Master
|
||||
- Unstable
|
||||
- Older*
|
||||
@@ -141,14 +136,13 @@ body:
|
||||
- **FFmpeg Version**: [e.g. 5.1.2-Jellyfin]
|
||||
- **Playback**: [Direct Play, Remux, Direct Stream, Transcode]
|
||||
- **Hardware Acceleration**: [e.g. none, VAAPI, NVENC, etc.]
|
||||
- **CPU Model**: [e.g. AMD Ryzen 5 9600X, Intel Core i7-8565U, etc.]
|
||||
- **GPU Model**: [e.g. none, UHD630, GTX1050, 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]
|
||||
- **Jellyfin Data Storage & Filesystem**: [e.g. local SATA SSD - ext4, local HDD - NTFS]
|
||||
- **Media Storage & Filesystem**: [e.g. Local HDD - ext4, SMB Share]
|
||||
- **Jellyfin Data Storage**: [e.g. local SATA SSD, local HDD]
|
||||
- **Media Storage**: [e.g. Local HDD, SMB Share]
|
||||
- **External Integrations**: [e.g. Jellystat, Jellyseerr]
|
||||
value: |
|
||||
- OS:
|
||||
@@ -159,14 +153,13 @@ body:
|
||||
- FFmpeg Version:
|
||||
- Playback Method:
|
||||
- Hardware Acceleration:
|
||||
- CPU Model:
|
||||
- GPU Model:
|
||||
- Plugins:
|
||||
- Reverse Proxy:
|
||||
- Base URL:
|
||||
- Networking:
|
||||
- Jellyfin Data Storage & Filesystem:
|
||||
- Media Storage & Filesystem:
|
||||
- Jellyfin Data Storage:
|
||||
- Media Storage:
|
||||
- External Integrations:
|
||||
render: markdown
|
||||
validations:
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<!--
|
||||
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our documentation.
|
||||
-->
|
||||
|
||||
**Changes**
|
||||
<!-- Describe your changes here in 1-5 sentences. -->
|
||||
|
||||
**Code assistance**
|
||||
<!-- If code assistance was used, describe how it contributed
|
||||
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
|
||||
|
||||
**Issues**
|
||||
<!-- Tag any issues that this PR solves here.
|
||||
ex. Fixes # -->
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '24 2 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'csharp' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
@@ -0,0 +1,159 @@
|
||||
name: ABI Compatibility
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
abi-head:
|
||||
name: ABI - HEAD
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
|
||||
abi-base:
|
||||
name: ABI - BASE
|
||||
if: ${{ github.base_ref != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Checkout common ancestor
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
|
||||
abi-diff:
|
||||
permissions:
|
||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||
|
||||
name: ABI - Difference
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- abi-head
|
||||
- abi-base
|
||||
|
||||
steps:
|
||||
- name: Download abi-head
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
path: abi-head
|
||||
|
||||
- name: Download abi-base
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
path: abi-base
|
||||
|
||||
- name: Setup ApiCompat
|
||||
run: |
|
||||
dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool
|
||||
|
||||
- name: Run ApiCompat
|
||||
id: diff
|
||||
run: |
|
||||
{
|
||||
echo 'body<<EOF'
|
||||
for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll Jellyfin.MediaEncoding.Keyframes.dll Jellyfin.Database.Implementations.dll; do
|
||||
COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 )"
|
||||
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
|
||||
printf "\n${file}\n${COMPAT_OUTPUT}\n"
|
||||
fi
|
||||
done
|
||||
echo EOF
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find difference comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
direction: last
|
||||
body-includes: abi-diff-workflow-comment
|
||||
|
||||
- name: Reply or edit difference comment (changed)
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.diff.outputs.body != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
body: |
|
||||
<!--abi-diff-workflow-comment-->
|
||||
<details>
|
||||
<summary>ABI Difference</summary>
|
||||
|
||||
```
|
||||
${{ steps.diff.outputs.body }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
- name: Reply or edit difference comment (unchanged)
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.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
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
body: |
|
||||
<!--abi-diff-workflow-comment-->
|
||||
<details>
|
||||
<summary>ABI Difference</summary>
|
||||
|
||||
No changes to the ABI found. See history of this comment for previous changes.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,271 @@
|
||||
name: OpenAPI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
openapi-head:
|
||||
name: OpenAPI - HEAD
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
- name: Checkout common ancestor
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.0/openapi.json
|
||||
|
||||
openapi-diff:
|
||||
permissions:
|
||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||
|
||||
name: OpenAPI - Difference
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
- openapi-base
|
||||
steps:
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Download openapi-base
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
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: |
|
||||
# Read and fix markdown
|
||||
body=$(cat openapi-changes.md)
|
||||
# Write to workflow summary
|
||||
echo "$body" >> $GITHUB_STEP_SUMMARY
|
||||
# Set ApiChanged var
|
||||
if [ "$body" != '' ]; then
|
||||
echo "ApiChanged=1" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ApiChanged=0" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# Add header/footer for diff comment
|
||||
echo '<!--openapi-diff-workflow-comment-->' > openapi-changes-reply.md
|
||||
echo "<details>" >> openapi-changes-reply.md
|
||||
echo "<summary>Changes in OpenAPI specification found. Expand to see details.</summary>" >> openapi-changes-reply.md
|
||||
echo "" >> openapi-changes-reply.md
|
||||
echo "$body" >> openapi-changes-reply.md
|
||||
echo "" >> openapi-changes-reply.md
|
||||
echo "</details>" >> openapi-changes-reply.md
|
||||
- name: Find difference comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
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@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
body-path: openapi-changes-reply.md
|
||||
- name: Edit difference comment (unchanged)
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.read-diff.outputs.ApiChanged == '0' && 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.
|
||||
|
||||
publish-unstable:
|
||||
name: OpenAPI - Publish Unstable Spec
|
||||
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
steps:
|
||||
- name: Set unstable dated version
|
||||
id: version
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Upload openapi.json (unstable) to repository server
|
||||
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
source: openapi-head/openapi.json
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (unstable) into place
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
debug: false
|
||||
script_stop: false
|
||||
script: |
|
||||
if ! test -d /run/workflows; then
|
||||
sudo mkdir -p /run/workflows
|
||||
sudo chown ${{ secrets.REPO_USER }} /run/workflows
|
||||
fi
|
||||
(
|
||||
flock -x -w 300 200 || exit 1
|
||||
TGT_DIR="/srv/repository/main/openapi"
|
||||
LAST_SPEC="$( ls -lt ${TGT_DIR}/unstable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
|
||||
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
|
||||
if diff /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/unstable/${LAST_SPEC} &>/dev/null; then
|
||||
rm -r /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
|
||||
exit 0
|
||||
fi
|
||||
# Move new spec into place
|
||||
sudo mv /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
|
||||
# Delete previous jellyfin-openapi-unstable_previous.json
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
# Move current jellyfin-openapi-unstable.json symlink to jellyfin-openapi-unstable_previous.json
|
||||
sudo mv ${TGT_DIR}/jellyfin-openapi-unstable.json ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
# Create new jellyfin-openapi-unstable.json symlink
|
||||
sudo ln -s unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-unstable.json
|
||||
# Check that the previous openapi unstable spec link is correct
|
||||
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-unstable_previous.json )" != "unstable/${LAST_SPEC}" ]]; then
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
sudo ln -s unstable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
fi
|
||||
) 200>/run/workflows/openapi-unstable.lock
|
||||
|
||||
publish-stable:
|
||||
name: OpenAPI - Publish Stable Spec
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
steps:
|
||||
- name: Set version number
|
||||
id: version
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Upload openapi.json (stable) to repository server
|
||||
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
source: openapi-head/openapi.json
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (stable) into place
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
debug: false
|
||||
script_stop: false
|
||||
script: |
|
||||
if ! test -d /run/workflows; then
|
||||
sudo mkdir -p /run/workflows
|
||||
sudo chown ${{ secrets.REPO_USER }} /run/workflows
|
||||
fi
|
||||
(
|
||||
flock -x -w 300 200 || exit 1
|
||||
TGT_DIR="/srv/repository/main/openapi"
|
||||
LAST_SPEC="$( ls -lt ${TGT_DIR}/stable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
|
||||
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
|
||||
if diff /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/${LAST_SPEC} &>/dev/null; then
|
||||
rm -r /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
|
||||
exit 0
|
||||
fi
|
||||
# Move new spec into place
|
||||
sudo mv /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
|
||||
# Delete previous jellyfin-openapi-stable_previous.json
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
# Move current jellyfin-openapi-stable.json symlink to jellyfin-openapi-stable_previous.json
|
||||
sudo mv ${TGT_DIR}/jellyfin-openapi-stable.json ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
# Create new jellyfin-openapi-stable.json symlink
|
||||
sudo ln -s stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-stable.json
|
||||
# Check that the previous openapi stable spec link is correct
|
||||
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-stable_previous.json )" != "stable/${LAST_SPEC}" ]]; then
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
sudo ln -s stable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
fi
|
||||
) 200>/run/workflows/openapi-stable.lock
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# Run tests against the forked branch, but
|
||||
# do not allow access to secrets
|
||||
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflows-in-forked-repositories
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
SDK_VERSION: "9.0.x"
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
strategy:
|
||||
matrix:
|
||||
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
- name: Run DotNet CLI Tests
|
||||
run: >
|
||||
dotnet test Jellyfin.sln
|
||||
--configuration Release
|
||||
--collect:"XPlat Code Coverage"
|
||||
--settings tests/coverletArgs.runsettings
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
reporttypes: "Cobertura"
|
||||
|
||||
# TODO - which action / tool to use to publish code coverage results?
|
||||
# - name: Publish code coverage results
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Commands
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
- synchronize
|
||||
|
||||
permissions: {}
|
||||
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@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Automatic Rebase
|
||||
uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
rename:
|
||||
name: Rename
|
||||
if: contains(github.event.comment.body, '@jellyfin-bot rename') && github.event.comment.author_association == 'MEMBER'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
- name: install python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
- name: install python packages
|
||||
run: pip install -r rename/requirements.txt
|
||||
- name: run rename script
|
||||
run: python3 rename.py
|
||||
working-directory: ./rename
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Stale Issue Labeler
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
issues:
|
||||
name: Check for stale issues
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
|
||||
|
||||
If you have any questions you can use one of several ways to [contact us](https://jellyfin.org/contact).
|
||||
close-issue-message: |-
|
||||
This issue was closed due to inactivity.
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Check Issue Template
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
jobs:
|
||||
check_issue:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
- name: install python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
- name: install python packages
|
||||
run: pip install -r main-repo-triage/requirements.txt
|
||||
- name: check and comment issue
|
||||
working-directory: ./main-repo-triage
|
||||
run: python3 single_issue_gha.py
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Project Automation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
issue_comment:
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
project:
|
||||
name: Project board
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin' }}
|
||||
steps:
|
||||
- name: Remove from 'Current Release' project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Current Release
|
||||
action: delete
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add to 'Release Next' project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened'
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Release Next
|
||||
column: In progress
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add to 'Current Release' project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Current Release
|
||||
column: In progress
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Check number of comments from the team member
|
||||
if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER'
|
||||
id: member_comments
|
||||
run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)"
|
||||
|
||||
- name: Move issue to needs triage
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Issue Triage for Main Repo
|
||||
column: Needs triage
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add issue to triage project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: github.event.issue.pull_request == '' && github.event.action == 'opened'
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Issue Triage for Main Repo
|
||||
column: Pending response
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Merge Conflict Labeler
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
issue_comment:
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
label:
|
||||
name: Labeling
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }}
|
||||
steps:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Stale PR Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 */12 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
prs-stale-conflicts:
|
||||
name: Check PRs with merge conflicts
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
operations-per-run: 150
|
||||
# The merge conflict action will remove the label when updated
|
||||
remove-stale-when-updated: false
|
||||
days-before-stale: -1
|
||||
days-before-close: 90
|
||||
days-before-issue-close: -1
|
||||
stale-pr-label: merge conflict
|
||||
close-pr-message: |-
|
||||
This PR has been closed due to having unresolved merge conflicts.
|
||||
@@ -0,0 +1,82 @@
|
||||
name: '🆙 Auto bump_version'
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
TAG_BRANCH:
|
||||
required: true
|
||||
description: release-x.y.z
|
||||
NEXT_VERSION:
|
||||
required: true
|
||||
description: x.y.z
|
||||
|
||||
jobs:
|
||||
auto_bump_version:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' && !contains(github.event.release.tag_name, 'rc') }}
|
||||
env:
|
||||
TAG_BRANCH: ${{ github.event.release.target_commitish }}
|
||||
steps:
|
||||
- name: Wait for deploy checks to finish
|
||||
uses: jitterbit/await-check-suites@292a541bb7618078395b2ce711a0d89cfb8a568a # v1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
intervalSeconds: 60
|
||||
timeoutSeconds: 3600
|
||||
|
||||
- name: Setup YQ
|
||||
uses: chrisdickinson/setup-yq@latest
|
||||
with:
|
||||
yq-version: v4.9.8
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
- name: Setup EnvVars
|
||||
run: |-
|
||||
CURRENT_VERSION=$(yq e '.version' build.yaml)
|
||||
CURRENT_MAJOR_MINOR=${CURRENT_VERSION%.*}
|
||||
CURRENT_PATCH=${CURRENT_VERSION##*.}
|
||||
echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_ENV
|
||||
echo "CURRENT_MAJOR_MINOR=${CURRENT_MAJOR_MINOR}" >> $GITHUB_ENV
|
||||
echo "CURRENT_PATCH=${CURRENT_PATCH}" >> $GITHUB_ENV
|
||||
echo "NEXT_VERSION=${CURRENT_MAJOR_MINOR}.$(($CURRENT_PATCH + 1))" >> $GITHUB_ENV
|
||||
|
||||
- name: Run bump_version
|
||||
run: ./bump_version ${{ env.NEXT_VERSION }}
|
||||
|
||||
- name: Commit Changes
|
||||
run: |-
|
||||
git config user.name "jellyfin-bot"
|
||||
git config user.email "team@jellyfin.org"
|
||||
git checkout ${{ env.TAG_BRANCH }}
|
||||
git commit -am "Bump version to ${{ env.NEXT_VERSION }}"
|
||||
git push origin ${{ env.TAG_BRANCH }}
|
||||
|
||||
manual_bump_version:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
env:
|
||||
TAG_BRANCH: ${{ github.event.inputs.TAG_BRANCH }}
|
||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
- name: Run bump_version
|
||||
run: ./bump_version ${{ env.NEXT_VERSION }}
|
||||
|
||||
- name: Commit Changes
|
||||
run: |-
|
||||
git config user.name "jellyfin-bot"
|
||||
git config user.email "team@jellyfin.org"
|
||||
git checkout ${{ env.TAG_BRANCH }}
|
||||
git commit -am "Bump version to ${{ env.NEXT_VERSION }}"
|
||||
git push origin ${{ env.TAG_BRANCH }}
|
||||
@@ -19,7 +19,6 @@ local.properties
|
||||
.classpath
|
||||
.settings/
|
||||
.loadpath
|
||||
*.lscache
|
||||
|
||||
# External tool builders
|
||||
.externalToolBuilders/
|
||||
|
||||
Vendored
+3
-3
@@ -6,7 +6,7 @@
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
|
||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
||||
"console": "internalConsole",
|
||||
@@ -22,7 +22,7 @@
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
|
||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll",
|
||||
"args": ["--nowebclient"],
|
||||
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
||||
"console": "internalConsole",
|
||||
@@ -34,7 +34,7 @@
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
|
||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll",
|
||||
"args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"],
|
||||
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
||||
"console": "internalConsole",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
|
||||
steps:
|
||||
# Restore, build and test in one step so the NuGet cache stays on the step's
|
||||
# own ephemeral storage instead of the 10Gi workspace volume.
|
||||
# global.json pins the .NET 10 SDK (10.0.0, rollForward latestMinor).
|
||||
- name: build-test
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
environment:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
commands:
|
||||
- dotnet --info
|
||||
- dotnet restore Jellyfin.sln
|
||||
- dotnet build Jellyfin.sln -c Release --no-restore
|
||||
# libSkiaSharp needs fontconfig to load and the SDK image does not ship it.
|
||||
# The mirror occasionally serves a half-synced index, so update is retried.
|
||||
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
|
||||
- apt-get install -y --no-install-recommends libfontconfig1
|
||||
- ldconfig -p | grep -q libfontconfig
|
||||
# BackupServiceTests is excluded because BackupService refuses to write a
|
||||
# backup with less than 5GiB free on its target path. The clone and build
|
||||
# output fill 5.7G of the 9.8G workspace volume, leaving 4.1G. Raising the
|
||||
# agent's workspace volume size is the only way to run it here.
|
||||
- df -h .
|
||||
- dotnet test Jellyfin.sln -c Release --no-build --verbosity minimal --filter "Category!=RequiresDocker&FullyQualifiedName!~Integration&FullyQualifiedName!~BackupServiceTests"
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-ha-src
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
ephemeral-storage: 10Gi
|
||||
limits:
|
||||
memory: 8Gi
|
||||
cpu: 4
|
||||
ephemeral-storage: 20Gi
|
||||
|
||||
# The PostgreSQL migration tests are the only ones that run the startup migration chain against
|
||||
# the provider production uses, and the filter above has always skipped them.
|
||||
# The server runs inside this step: the kubernetes backend has no docker daemon for
|
||||
# testcontainers, and a postgres service container deadlocks the step because the backend mounts
|
||||
# the ReadWriteOnce workspace volume into service pods and schedules them on another node.
|
||||
# Its data directory lives on the step's ephemeral storage, not on the workspace volume.
|
||||
# Scoped to Jellyfin.Server.Tests: the three classes in Jellyfin.Database.Tests.PostgreSQL still
|
||||
# start their own container, and PostgreSqlProviderTests already fails on main - on an EF 10
|
||||
# scalar query and on its own data - which a third test in the class then inherits.
|
||||
- name: postgres-migration-chain
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
depends_on:
|
||||
- build-test
|
||||
environment:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
JELLYFIN_TEST_POSTGRES: "Host=127.0.0.1;Port=5432;Database=postgres;Username=postgres"
|
||||
commands:
|
||||
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
|
||||
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql
|
||||
- install -d -o postgres -g postgres /tmp/pgdata /tmp/pgrun
|
||||
- PGBIN=$(ls -d /usr/lib/postgresql/*/bin | tail -1)
|
||||
- su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres"
|
||||
- su postgres -c "$PGBIN/pg_ctl -D /tmp/pgdata -o \"-c listen_addresses=127.0.0.1 -k /tmp/pgrun\" -l /tmp/pg.log -w start"
|
||||
- dotnet build tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release
|
||||
- dotnet test tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-ha-src
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
ephemeral-storage: 6Gi
|
||||
limits:
|
||||
memory: 6Gi
|
||||
cpu: 4
|
||||
ephemeral-storage: 12Gi
|
||||
+15
-35
@@ -1,6 +1,5 @@
|
||||
# Jellyfin Contributors
|
||||
|
||||
- [0x25CBFC4F](https://github.com/0x25CBFC4F)
|
||||
- [1337joe](https://github.com/1337joe)
|
||||
- [97carmine](https://github.com/97carmine)
|
||||
- [Abbe98](https://github.com/Abbe98)
|
||||
@@ -15,7 +14,7 @@
|
||||
- [bilde2910](https://github.com/bilde2910)
|
||||
- [bfayers](https://github.com/bfayers)
|
||||
- [BnMcG](https://github.com/BnMcG)
|
||||
- [Bond_009](https://github.com/Bond-009)
|
||||
- [Bond-009](https://github.com/Bond-009)
|
||||
- [brianjmurrell](https://github.com/brianjmurrell)
|
||||
- [bugfixin](https://github.com/bugfixin)
|
||||
- [chaosinnovator](https://github.com/chaosinnovator)
|
||||
@@ -32,7 +31,6 @@
|
||||
- [DaveChild](https://github.com/DaveChild)
|
||||
- [DavidFair](https://github.com/DavidFair)
|
||||
- [Delgan](https://github.com/Delgan)
|
||||
- [DerMaddis](https://github.com/dermaddis)
|
||||
- [Derpipose](https://github.com/Derpipose)
|
||||
- [dcrdev](https://github.com/dcrdev)
|
||||
- [dhartung](https://github.com/dhartung)
|
||||
@@ -56,7 +54,6 @@
|
||||
- [geilername](https://github.com/geilername)
|
||||
- [GermanCoding](https://github.com/GermanCoding)
|
||||
- [gnattu](https://github.com/gnattu)
|
||||
- [gnuyent](https://github.com/gnuyent)
|
||||
- [GodTamIt](https://github.com/GodTamIt)
|
||||
- [grafixeyehero](https://github.com/grafixeyehero)
|
||||
- [h1nk](https://github.com/h1nk)
|
||||
@@ -64,7 +61,6 @@
|
||||
- [HelloWorld017](https://github.com/HelloWorld017)
|
||||
- [ikomhoog](https://github.com/ikomhoog)
|
||||
- [iwalton3](https://github.com/iwalton3)
|
||||
- [Jakob Kukla](https://github.com/jakobkukla)
|
||||
- [jftuga](https://github.com/jftuga)
|
||||
- [jkhsjdhjs](https://github.com/jkhsjdhjs)
|
||||
- [jmshrv](https://github.com/jmshrv)
|
||||
@@ -73,10 +69,8 @@
|
||||
- [JustAMan](https://github.com/JustAMan)
|
||||
- [justinfenn](https://github.com/justinfenn)
|
||||
- [JPVenson](https://github.com/JPVenson)
|
||||
- [JPUC1143](https://github.com/Jpuc1143/)
|
||||
- [KerryRJ](https://github.com/KerryRJ)
|
||||
- [Larvitar](https://github.com/Larvitar)
|
||||
- [lbenini](https://github.com/lbenini)
|
||||
- [LeoVerto](https://github.com/LeoVerto)
|
||||
- [Liggy](https://github.com/Liggy)
|
||||
- [lmaonator](https://github.com/lmaonator)
|
||||
@@ -89,20 +83,15 @@
|
||||
- [marius-luca-87](https://github.com/marius-luca-87)
|
||||
- [mark-monteiro](https://github.com/mark-monteiro)
|
||||
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
|
||||
- [Martin Reuter](https://github.com/reuterma24)
|
||||
- [Matt Teahan](https://github.com/matt-teahan)
|
||||
- [Matt07211](https://github.com/Matt07211)
|
||||
- [Matthew Jones](https://github.com/matthew-jones-uk)
|
||||
- [Maxr1998](https://github.com/Maxr1998)
|
||||
- [mcarlton00](https://github.com/mcarlton00)
|
||||
- [Michael McElroy](https://github.com/mcmcelro)
|
||||
- [mitchfizz05](https://github.com/mitchfizz05)
|
||||
- [mohd-akram](https://github.com/mohd-akram)
|
||||
- [MrTimscampi](https://github.com/MrTimscampi)
|
||||
- [n8225](https://github.com/n8225)
|
||||
- [Nalsai](https://github.com/Nalsai)
|
||||
- [Narfinger](https://github.com/Narfinger)
|
||||
- [Nathan McCrina](https://github.com/nfmccrina)
|
||||
- [NathanPickard](https://github.com/NathanPickard)
|
||||
- [neilsb](https://github.com/neilsb)
|
||||
- [nevado](https://github.com/nevado)
|
||||
@@ -113,20 +102,16 @@
|
||||
- [OancaAndrei](https://github.com/OancaAndrei)
|
||||
- [obradovichv](https://github.com/obradovichv)
|
||||
- [oddstr13](https://github.com/oddstr13)
|
||||
- [olsh](https://github.com/olsh)
|
||||
- [orryverducci](https://github.com/orryverducci)
|
||||
- [PCEWLKR](https://github.com/PCEWLKR)
|
||||
- [petermcneil](https://github.com/petermcneil)
|
||||
- [Phlogi](https://github.com/Phlogi)
|
||||
- [pjeanjean](https://github.com/pjeanjean)
|
||||
- [ploughpuff](https://github.com/ploughpuff)
|
||||
- [poytiis](https://github.com/poytiis)
|
||||
- [pR0Ps](https://github.com/pR0Ps)
|
||||
- [PrplHaz4](https://github.com/PrplHaz4)
|
||||
- [RazeLighter777](https://github.com/RazeLighter777)
|
||||
- [redSpoutnik](https://github.com/redSpoutnik)
|
||||
- [ringmatter](https://github.com/ringmatter)
|
||||
- [Robert Lützner](https://github.com/rluetzner)
|
||||
- [ryan-hartzell](https://github.com/ryan-hartzell)
|
||||
- [s0urcelab](https://github.com/s0urcelab)
|
||||
- [sachk](https://github.com/sachk)
|
||||
@@ -142,8 +127,6 @@
|
||||
- [sl1288](https://github.com/sl1288)
|
||||
- [Smith00101010](https://github.com/Smith00101010)
|
||||
- [sorinyo2004](https://github.com/sorinyo2004)
|
||||
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
|
||||
- [st7105](https://github.com/st7105)
|
||||
- [sparky8251](https://github.com/sparky8251)
|
||||
- [spookbits](https://github.com/spookbits)
|
||||
- [ssenart](https://github.com/ssenart)
|
||||
@@ -166,14 +149,12 @@
|
||||
- [twinkybot](https://github.com/twinkybot)
|
||||
- [Ullmie02](https://github.com/Ullmie02)
|
||||
- [Unhelpful](https://github.com/Unhelpful)
|
||||
- [Utku Özdemir](https://github.com/utkuozdemir)
|
||||
- [viaregio](https://github.com/viaregio)
|
||||
- [vitorsemeano](https://github.com/vitorsemeano)
|
||||
- [voodoos](https://github.com/voodoos)
|
||||
- [whooo](https://github.com/whooo)
|
||||
- [WiiPlayer2](https://github.com/WiiPlayer2)
|
||||
- [WillWill56](https://github.com/WillWill56)
|
||||
- [WizardOfYendor1](https://github.com/WizardOfYendor1)
|
||||
- [wtayl0r](https://github.com/wtayl0r)
|
||||
- [Wuerfelbecher](https://github.com/Wuerfelbecher)
|
||||
- [Wunax](https://github.com/Wunax)
|
||||
@@ -183,7 +164,6 @@
|
||||
- [XVicarious](https://github.com/XVicarious)
|
||||
- [YouKnowBlom](https://github.com/YouKnowBlom)
|
||||
- [ZachPhelan](https://github.com/ZachPhelan)
|
||||
- [ZeusCraft10](https://github.com/ZeusCraft10)
|
||||
- [KristupasSavickas](https://github.com/KristupasSavickas)
|
||||
- [Pusta](https://github.com/pusta)
|
||||
- [nielsvanvelzen](https://github.com/nielsvanvelzen)
|
||||
@@ -226,21 +206,8 @@
|
||||
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||
- [TokerX](https://github.com/TokerX)
|
||||
- [GeneMarks](https://github.com/GeneMarks)
|
||||
- [Kirill Nikiforov](https://github.com/allmazz)
|
||||
- [bjorntp](https://github.com/bjorntp)
|
||||
- [martenumberto](https://github.com/martenumberto)
|
||||
- [ZeusCraft10](https://github.com/ZeusCraft10)
|
||||
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
|
||||
- [LiHRaM](https://github.com/LiHRaM)
|
||||
- [MSalman5230](https://github.com/MSalman5230)
|
||||
- [dwandw](https://github.com/dwandw)
|
||||
- [Lampan-git](https://github.com/Lampan-git)
|
||||
- [elio42](https://github.com/elio42)
|
||||
- [rwebster85](https://github.com/rwebster85)
|
||||
- [Florin-Popescu](https://github.com/Florin-Popescu)
|
||||
- [m0g3r](https://github.com/m0g3r)
|
||||
- [martin-77](https://github.com/martin-77)
|
||||
- [Oggeb1](https://github.com/Oggeb1)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
@@ -251,7 +218,7 @@
|
||||
- [Mark2xv](https://github.com/Mark2xv)
|
||||
- [ScottRapsey](https://github.com/ScottRapsey)
|
||||
- [skynet600](https://github.com/skynet600)
|
||||
- [Cheesegeezer](https://github.com/Cheesegeezer)
|
||||
- [Cheesegeezer](https://githum.com/Cheesegeezer)
|
||||
- [Radeon](https://github.com/radeonorama)
|
||||
- [gcw07](https://github.com/gcw07)
|
||||
- [SivaramAdhiappan](https://github.com/shivaram1190)
|
||||
@@ -304,3 +271,16 @@
|
||||
- [tikuf](https://github.com/tikuf/)
|
||||
- [Tim Hobbs](https://github.com/timhobbs)
|
||||
- [SvenVandenbrande](https://github.com/SvenVandenbrande)
|
||||
- [olsh](https://github.com/olsh)
|
||||
- [lbenini](https://github.com/lbenini)
|
||||
- [gnuyent](https://github.com/gnuyent)
|
||||
- [Matthew Jones](https://github.com/matthew-jones-uk)
|
||||
- [Jakob Kukla](https://github.com/jakobkukla)
|
||||
- [Utku Özdemir](https://github.com/utkuozdemir)
|
||||
- [JPUC1143](https://github.com/Jpuc1143/)
|
||||
- [0x25CBFC4F](https://github.com/0x25CBFC4F)
|
||||
- [Robert Lützner](https://github.com/rluetzner)
|
||||
- [Nathan McCrina](https://github.com/nfmccrina)
|
||||
- [Martin Reuter](https://github.com/reuterma24)
|
||||
- [Michael McElroy](https://github.com/mcmcelro)
|
||||
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
|
||||
|
||||
+60
-58
@@ -4,94 +4,96 @@
|
||||
</PropertyGroup>
|
||||
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||
<ItemGroup Label="Package Dependencies">
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.8" />
|
||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
|
||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.1" />
|
||||
<PackageVersion Include="BDInfo" Version="0.8.0" />
|
||||
<PackageVersion Include="BitFaster.Caching" Version="2.6.1" />
|
||||
<PackageVersion Include="BitFaster.Caching" Version="2.5.4" />
|
||||
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageVersion Include="Diacritics" Version="4.1.8" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.4" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||
<PackageVersion Include="Ignore" Version="0.2.1" />
|
||||
<PackageVersion Include="Jellyfin.XmlTv" Version="10.12.0-pre1" />
|
||||
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="PDFtoImage" Version="5.2.1" />
|
||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||
<PackageVersion Include="Polly" Version="8.7.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.50.4" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
|
||||
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
|
||||
<PackageVersion Include="SkiaSharp" Version="3.116.1" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StackExchange.Redis" Version="2.13.17" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.2.1" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.11" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
|
||||
<PackageVersion Include="Testcontainers.Redis" Version="4.15.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.16.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.7.0" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageVersion Include="Xunit.v3.Priority" Version="1.1.18" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.9.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Restore dependencies first (layer-cache friendly)
|
||||
COPY ["Jellyfin.sln", "global.json", "nuget.config", "Directory.Build.props", "Directory.Packages.props", "./"]
|
||||
COPY ["SharedVersion.cs", "BannedSymbols.txt", "stylecop.json", "./"]
|
||||
|
||||
# Copy all project files so dotnet restore can resolve the full dependency graph
|
||||
COPY Emby.Naming/ Emby.Naming/
|
||||
COPY Emby.Photos/ Emby.Photos/
|
||||
COPY Emby.Server.Implementations/ Emby.Server.Implementations/
|
||||
COPY Jellyfin.Api/ Jellyfin.Api/
|
||||
COPY Jellyfin.Data/ Jellyfin.Data/
|
||||
COPY Jellyfin.Server/ Jellyfin.Server/
|
||||
COPY Jellyfin.Server.Implementations/ Jellyfin.Server.Implementations/
|
||||
COPY MediaBrowser.Common/ MediaBrowser.Common/
|
||||
COPY MediaBrowser.Controller/ MediaBrowser.Controller/
|
||||
COPY MediaBrowser.LocalMetadata/ MediaBrowser.LocalMetadata/
|
||||
COPY MediaBrowser.MediaEncoding/ MediaBrowser.MediaEncoding/
|
||||
COPY MediaBrowser.Model/ MediaBrowser.Model/
|
||||
COPY MediaBrowser.Providers/ MediaBrowser.Providers/
|
||||
COPY MediaBrowser.XbmcMetadata/ MediaBrowser.XbmcMetadata/
|
||||
COPY src/ src/
|
||||
|
||||
RUN dotnet restore Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--runtime linux-x64
|
||||
|
||||
# Publish the server (and all transitive dependencies, including the
|
||||
# PostgreSQL provider assembly added by this fork).
|
||||
# Note: TreatWarningsAsErrors is disabled for the Docker build — StyleCop
|
||||
# analyzer violations in upstream src/ projects would otherwise block the
|
||||
# image build. StyleCop is enforced in the CI pipeline, not the Dockerfile.
|
||||
RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--configuration Release \
|
||||
--runtime linux-x64 \
|
||||
--self-contained false \
|
||||
--no-restore \
|
||||
-p:TreatWarningsAsErrors=false \
|
||||
--output /app
|
||||
|
||||
# ── Web client stage ──────────────────────────────────────────────────────────
|
||||
# Install jellyfin-web via the official Jellyfin apt repo.
|
||||
# Package suffix in the bookworm repo is +deb12 (e.g. 12.0+deb12).
|
||||
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||
# The web client version must match the server version this fork is based on.
|
||||
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
|
||||
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
|
||||
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends "jellyfin-web=12.0+deb12" \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
|
||||
# libicu, libssl, and liblttng-ust are already present in the dotnet/aspnet base image.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
fontconfig \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /jellyfin
|
||||
|
||||
COPY --from=build /app .
|
||||
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
|
||||
|
||||
# Jellyfin default ports
|
||||
EXPOSE 8096
|
||||
EXPOSE 8920
|
||||
|
||||
# Data / config volumes
|
||||
VOLUME ["/config", "/cache", "/media"]
|
||||
|
||||
ENV JELLYFIN_DATA_DIR=/config \
|
||||
JELLYFIN_CACHE_DIR=/cache \
|
||||
JELLYFIN_LOG_DIR=/config/log \
|
||||
JELLYFIN_CONFIG_DIR=/config
|
||||
|
||||
ENTRYPOINT ["./jellyfin", \
|
||||
"--datadir", "/config", \
|
||||
"--cachedir", "/cache", \
|
||||
"--webdir", "/jellyfin/jellyfin-web"]
|
||||
@@ -1,55 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Runtime-only image — the .NET publish step runs on the CI host (runner),
|
||||
# not inside this Dockerfile.
|
||||
|
||||
# ── Web client stage ──────────────────────────────────────────────────────────
|
||||
# Install jellyfin-web via the official Jellyfin apt repo.
|
||||
# Package suffix in the bookworm repo is +deb12 (e.g. 12.0+deb12).
|
||||
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||
# The web client version must match the server version this fork is based on.
|
||||
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
|
||||
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
|
||||
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends "jellyfin-web=12.0+deb12" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
fontconfig \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /jellyfin
|
||||
|
||||
# Copy the pre-built publish output produced by `dotnet publish` on the CI host.
|
||||
COPY publish-output/ .
|
||||
# Copy the jellyfin-web client assets from the webclient stage.
|
||||
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
|
||||
|
||||
# Jellyfin default ports
|
||||
EXPOSE 8096
|
||||
EXPOSE 8920
|
||||
|
||||
# Data / config volumes
|
||||
VOLUME ["/config", "/cache", "/media"]
|
||||
|
||||
ENV JELLYFIN_DATA_DIR=/config \
|
||||
JELLYFIN_CACHE_DIR=/cache \
|
||||
JELLYFIN_LOG_DIR=/config/log
|
||||
|
||||
ENTRYPOINT ["./jellyfin", \
|
||||
"--datadir", "/config", \
|
||||
"--cachedir", "/cache", \
|
||||
"--webdir", "/jellyfin/jellyfin-web"]
|
||||
@@ -1,5 +1,3 @@
|
||||
#pragma warning disable CA1815
|
||||
|
||||
namespace Emby.Naming.AudioBook
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Emby.Naming.Book
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class to retrieve basic metadata from a book filename.
|
||||
/// </summary>
|
||||
public static partial class BookFileNameParser
|
||||
{
|
||||
private const string NameMatchGroup = "name";
|
||||
private const string IndexMatchGroup = "index";
|
||||
private const string YearMatchGroup = "year";
|
||||
private const string SeriesNameMatchGroup = "seriesName";
|
||||
|
||||
private static readonly Regex[] _nameMatches =
|
||||
[
|
||||
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required
|
||||
new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)(?:\.0)?((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)(?:\.0)?((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"^(?<index>[0-9]+)(?:\.0)?\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
|
||||
// last resort matches the whole string as the name
|
||||
new Regex(@"(?<name>.*)")
|
||||
];
|
||||
|
||||
[GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")]
|
||||
private static partial Regex ComicRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Parse a filename name to retrieve the book name, series name, index, and year.
|
||||
/// </summary>
|
||||
/// <param name="name">Book filename to parse for information.</param>
|
||||
/// <returns>Returns <see cref="BookFileNameParserResult"/> object.</returns>
|
||||
public static BookFileNameParserResult Parse(string? name)
|
||||
{
|
||||
var result = new BookFileNameParserResult();
|
||||
|
||||
if (name == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var regex in _nameMatches)
|
||||
{
|
||||
var match = regex.Match(name);
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
|
||||
{
|
||||
var comicMatch = ComicRegex().Match(nameGroup.Value.Trim());
|
||||
|
||||
if (comicMatch.Success)
|
||||
{
|
||||
if (comicMatch.Groups.TryGetValue("volume", out Group? volumeGroup) && volumeGroup.Success && int.TryParse(volumeGroup.ValueSpan, out var volume))
|
||||
{
|
||||
result.ParentIndex = volume;
|
||||
}
|
||||
|
||||
if (comicMatch.Groups.TryGetValue("chapter", out Group? chapterGroup) && chapterGroup.Success && int.TryParse(chapterGroup.ValueSpan, out var chapter))
|
||||
{
|
||||
result.Index = chapter;
|
||||
}
|
||||
}
|
||||
|
||||
result.Name = nameGroup.ValueSpan.Trim().ToString();
|
||||
}
|
||||
|
||||
if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index))
|
||||
{
|
||||
result.Index = index;
|
||||
}
|
||||
|
||||
if (match.Groups.TryGetValue(YearMatchGroup, out Group? yearGroup) && yearGroup.Success && int.TryParse(yearGroup.Value, out var year))
|
||||
{
|
||||
result.Year = year;
|
||||
}
|
||||
|
||||
if (match.Groups.TryGetValue(SeriesNameMatchGroup, out Group? seriesGroup) && seriesGroup.Success)
|
||||
{
|
||||
result.SeriesName = seriesGroup.Value.Trim();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
namespace Emby.Naming.Book
|
||||
{
|
||||
/// <summary>
|
||||
/// Data object used to pass metadata parsed from a book filename.
|
||||
/// </summary>
|
||||
public class BookFileNameParserResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BookFileNameParserResult"/> class.
|
||||
/// </summary>
|
||||
public BookFileNameParserResult()
|
||||
{
|
||||
Name = null;
|
||||
Index = null;
|
||||
ParentIndex = null;
|
||||
Year = null;
|
||||
SeriesName = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the book.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the book index.
|
||||
/// </summary>
|
||||
public int? Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parent index number.
|
||||
/// </summary>
|
||||
public int? ParentIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the publication year.
|
||||
/// </summary>
|
||||
public int? Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the series name.
|
||||
/// </summary>
|
||||
public string? SeriesName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ namespace Emby.Naming.Common
|
||||
".nrg",
|
||||
".nsv",
|
||||
".nuv",
|
||||
".ogg",
|
||||
".ogm",
|
||||
".ogv",
|
||||
".pva",
|
||||
@@ -151,8 +152,8 @@ namespace Emby.Naming.Common
|
||||
|
||||
CleanStrings =
|
||||
[
|
||||
@"^\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|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|web-dl|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)(?=[ _\,\.\(\)\[\]\-]|$)",
|
||||
@"^\s*(?<cleaned>.+?)((\s*\[[^\]]+\]\s*)+)(\.[^\s]+)?$",
|
||||
@"^\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|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|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*$",
|
||||
@@ -224,7 +225,6 @@ namespace Emby.Naming.Common
|
||||
".afc",
|
||||
".amf",
|
||||
".aif",
|
||||
".aifc",
|
||||
".aiff",
|
||||
".alac",
|
||||
".amr",
|
||||
@@ -361,10 +361,7 @@ namespace Emby.Naming.Common
|
||||
// Not a Kodi rule as well, but the expression below also causes false positives,
|
||||
// so we make sure this one gets tested first.
|
||||
// "Foo Bar 889"
|
||||
// Names carrying an SxxEyy marker are excluded because the Kodi expression above already covers them.
|
||||
// Without that guard this expression reads digits out of the title instead, turning
|
||||
// "S01E01 1-23-45 [Bluray-1080p]" into episodes 1 through 45.
|
||||
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?![^\\\/]*[Ss][0-9]+[][ ._-]*[Ee][0-9]+)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
|
||||
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
|
||||
{
|
||||
IsNamed = true
|
||||
},
|
||||
@@ -381,14 +378,6 @@ namespace Emby.Naming.Common
|
||||
IsNamed = true
|
||||
},
|
||||
|
||||
// "Name - 101.mkv", "Name - 101 [720p].mkv", "Name - 101 (2020).mkv"
|
||||
// Handles absolute episode numbers with hyphen delimiter (common in anime)
|
||||
// Without brackets (bracketed version handled above)
|
||||
new EpisodeExpression(@".*[\\\/](?<seriesname>[^\\\/]+?)[\s_]+-[\s_]+(?<epnumber>[0-9]+)[\s_]*(?:\[.*?\]|\(.*?\))*[\s_]*(?:\.\w+)?$")
|
||||
{
|
||||
IsNamed = true
|
||||
},
|
||||
|
||||
// /server/anything_102.mp4
|
||||
// /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv
|
||||
// /server/anything_1996.11.14.mp4
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
@@ -36,7 +36,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Naming</PackageId>
|
||||
<VersionPrefix>12.0.0</VersionPrefix>
|
||||
<VersionPrefix>10.11.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -44,14 +44,7 @@ namespace Emby.Naming.ExternalFiles
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path.AsSpan());
|
||||
|
||||
// .idx carries VobSub per-track language metadata. Recognize it here rather
|
||||
// than adding it to NamingOptions.SubtitleFileExtensions, which also gates
|
||||
// subtitle uploads/saves.
|
||||
var isVobSubIndex = _type == DlnaProfileType.Subtitle && extension.Equals(".idx", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isVobSubIndex
|
||||
&& !(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
&& !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
&& !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
@@ -77,7 +70,7 @@ namespace Emby.Naming.ExternalFiles
|
||||
|
||||
if (lastSeparator == -1)
|
||||
{
|
||||
break;
|
||||
break;
|
||||
}
|
||||
|
||||
string currentSlice = languageString[lastSeparator..];
|
||||
|
||||
@@ -125,7 +125,7 @@ namespace Emby.Naming.TV
|
||||
result.Success = true;
|
||||
}
|
||||
}
|
||||
else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date))
|
||||
else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date))
|
||||
{
|
||||
result.Year = date.Year;
|
||||
result.Month = date.Month;
|
||||
@@ -158,9 +158,7 @@ namespace Emby.Naming.TV
|
||||
if (nextIndex >= name.Length
|
||||
|| !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal))
|
||||
{
|
||||
// A range cannot end before it starts, so a lower number belongs to the episode title rather than to a range.
|
||||
if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)
|
||||
&& num >= result.EpisodeNumber)
|
||||
if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
|
||||
{
|
||||
result.EndingEpisodeNumber = num;
|
||||
}
|
||||
@@ -228,7 +226,7 @@ namespace Emby.Naming.TV
|
||||
info.SeriesName = result.SeriesName;
|
||||
}
|
||||
|
||||
if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber)
|
||||
if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue)
|
||||
{
|
||||
info.EndingEpisodeNumber = result.EndingEpisodeNumber;
|
||||
}
|
||||
|
||||
@@ -10,25 +10,17 @@ namespace Emby.Naming.TV
|
||||
/// </summary>
|
||||
public static partial class SeasonPathParser
|
||||
{
|
||||
private const string SeasonKeywordPattern =
|
||||
@"시즌|シーズン|сезон" +
|
||||
@"|season|sæson|saison|staffel|series|stagione|säsong|seizoen|seasong" +
|
||||
@"|sezon|sezona|sezóna|sezonul|série|séria|serie|seria|temporada|kausi";
|
||||
|
||||
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
|
||||
|
||||
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ProcessPre();
|
||||
|
||||
[GeneratedRegex(@"^\s*(?:" + SeasonKeywordPattern + @")\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ProcessPost();
|
||||
|
||||
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
|
||||
private static partial Regex SeasonPrefix();
|
||||
|
||||
[GeneratedRegex(SeasonKeywordPattern, RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SeasonKeyword();
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse season number from path.
|
||||
/// </summary>
|
||||
@@ -99,25 +91,14 @@ namespace Emby.Naming.TV
|
||||
return (val, true);
|
||||
}
|
||||
|
||||
bool isMixedLibrary = !supportNumericSeasonFolders && !supportSpecialAliases;
|
||||
var preMatch = ProcessPre().Match(filename);
|
||||
if (preMatch.Success)
|
||||
{
|
||||
if (isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
|
||||
{
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
return CheckMatch(preMatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
var postMatch = ProcessPost().Match(filename);
|
||||
if (postMatch.Success && isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
|
||||
{
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
return CheckMatch(postMatch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,5 @@ namespace Emby.Naming.TV
|
||||
/// </summary>
|
||||
/// <value>The name of the series.</value>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the year of the series.
|
||||
/// </summary>
|
||||
/// <value>The year of the series.</value>
|
||||
public int? Year { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,6 @@ namespace Emby.Naming.TV
|
||||
[GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")]
|
||||
private static partial Regex SeriesNameRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Regex that matches titles with year in parentheses. Captures the title (which may be
|
||||
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")]
|
||||
private static partial Regex TitleWithYearRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Resolve information about series from path.
|
||||
/// </summary>
|
||||
@@ -34,21 +27,6 @@ namespace Emby.Naming.TV
|
||||
{
|
||||
string seriesName = Path.GetFileName(path);
|
||||
|
||||
// First check if the filename matches a title with year pattern (handles numeric titles)
|
||||
if (!string.IsNullOrEmpty(seriesName))
|
||||
{
|
||||
var titleWithYearMatch = TitleWithYearRegex().Match(seriesName);
|
||||
if (titleWithYearMatch.Success)
|
||||
{
|
||||
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
|
||||
return new SeriesInfo(path)
|
||||
{
|
||||
Name = seriesName,
|
||||
Year = int.TryParse(titleWithYearMatch.Groups["year"].ValueSpan, out var year) ? year : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
SeriesPathParserResult result = SeriesPathParser.Parse(options, path);
|
||||
if (result.Success)
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ public static class TvParserHelpers
|
||||
/// <param name="status">The status string.</param>
|
||||
/// <param name="enumValue">The <see cref="SeriesStatus"/>.</param>
|
||||
/// <returns>Returns true if parsing was successful.</returns>
|
||||
public static bool TryParseSeriesStatus(string? status, out SeriesStatus? enumValue)
|
||||
public static bool TryParseSeriesStatus(string status, out SeriesStatus? enumValue)
|
||||
{
|
||||
if (Enum.TryParse(status, true, out SeriesStatus seriesStatus))
|
||||
{
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#pragma warning disable CA1815
|
||||
|
||||
namespace Emby.Naming.Video
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace Emby.Naming.Video
|
||||
var match = expression.Match(name);
|
||||
if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned))
|
||||
{
|
||||
newName = cleaned.Value.Trim();
|
||||
newName = cleaned.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,19 +52,14 @@ namespace Emby.Naming.Video
|
||||
while (path.Length > 0)
|
||||
{
|
||||
var index = path.IndexOfAny(delimiters);
|
||||
ReadOnlySpan<char> currentSlice;
|
||||
if (index == -1)
|
||||
{
|
||||
// No delimiter left, the last token is the remainder of the path
|
||||
currentSlice = path;
|
||||
path = default;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentSlice = path[..index];
|
||||
path = path[(index + 1)..];
|
||||
index = path.Length - 1;
|
||||
}
|
||||
|
||||
var currentSlice = path[..index];
|
||||
path = path[(index + 1)..];
|
||||
|
||||
if (!foundPrefix)
|
||||
{
|
||||
foundPrefix = currentSlice.Equals(rule.PrecedingToken, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -17,8 +17,8 @@ namespace Emby.Naming.Video
|
||||
{
|
||||
Name = name;
|
||||
|
||||
Files = [];
|
||||
AlternateVersions = [];
|
||||
Files = Array.Empty<VideoFileInfo>();
|
||||
AlternateVersions = Array.Empty<VideoFileInfo>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -40,10 +40,10 @@ namespace Emby.Naming.Video
|
||||
public IReadOnlyList<VideoFileInfo> Files { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the alternate versions. Each alternate may itself span multiple files.
|
||||
/// Gets or sets the alternate versions.
|
||||
/// </summary>
|
||||
/// <value>The alternate versions.</value>
|
||||
public IReadOnlyList<VideoInfo> AlternateVersions { get; set; }
|
||||
public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the extra type.
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Emby.Naming.Common;
|
||||
using Emby.Naming.TV;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Model.IO;
|
||||
|
||||
namespace Emby.Naming.Video
|
||||
@@ -14,23 +12,8 @@ namespace Emby.Naming.Video
|
||||
/// <summary>
|
||||
/// Resolves alternative versions and extras from list of video files.
|
||||
/// </summary>
|
||||
public partial class VideoListResolver
|
||||
public static partial class VideoListResolver
|
||||
{
|
||||
private static readonly StringComparer _numericOrdinalComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
|
||||
|
||||
private readonly NamingOptions _namingOptions;
|
||||
private readonly EpisodePathParser _episodePathParser;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="VideoListResolver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="namingOptions">The naming options.</param>
|
||||
public VideoListResolver(NamingOptions namingOptions)
|
||||
{
|
||||
_namingOptions = namingOptions;
|
||||
_episodePathParser = new EpisodePathParser(namingOptions);
|
||||
}
|
||||
|
||||
[GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ResolutionRegex();
|
||||
|
||||
@@ -41,12 +24,12 @@ namespace Emby.Naming.Video
|
||||
/// Resolves alternative versions and extras from list of video files.
|
||||
/// </summary>
|
||||
/// <param name="videoInfos">List of related video files.</param>
|
||||
/// <param name="namingOptions">The naming options.</param>
|
||||
/// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
|
||||
/// <param name="parseName">Whether to parse the name or use the filename.</param>
|
||||
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
|
||||
/// <param name="collectionType">The type of the containing collection, if known.</param>
|
||||
/// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
|
||||
public IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "", CollectionType? collectionType = null)
|
||||
public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "")
|
||||
{
|
||||
// Filter out all extras, otherwise they could cause stacks to not be resolved
|
||||
// See the unit test TestStackedWithTrailer
|
||||
@@ -54,7 +37,7 @@ namespace Emby.Naming.Video
|
||||
.Where(i => i.ExtraType is null)
|
||||
.Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
|
||||
|
||||
var stackResult = StackResolver.Resolve(nonExtras, _namingOptions).ToList();
|
||||
var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList();
|
||||
|
||||
var remainingFiles = new List<VideoFileInfo>();
|
||||
var standaloneMedia = new List<VideoFileInfo>();
|
||||
@@ -83,7 +66,7 @@ namespace Emby.Naming.Video
|
||||
{
|
||||
var info = new VideoInfo(stack.Name)
|
||||
{
|
||||
Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, _namingOptions, parseName, libraryRoot))
|
||||
Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName, libraryRoot))
|
||||
.OfType<VideoFileInfo>()
|
||||
.ToList()
|
||||
};
|
||||
@@ -102,9 +85,7 @@ namespace Emby.Naming.Video
|
||||
|
||||
if (supportMultiVersion)
|
||||
{
|
||||
list = collectionType is CollectionType.tvshows
|
||||
? GetEpisodesGroupedByVersion(list)
|
||||
: GetVideosGroupedByVersion(list);
|
||||
list = GetVideosGroupedByVersion(list, namingOptions);
|
||||
}
|
||||
|
||||
// Whatever files are left, just add them
|
||||
@@ -118,7 +99,7 @@ namespace Emby.Naming.Video
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos)
|
||||
private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions)
|
||||
{
|
||||
if (videos.Count == 0)
|
||||
{
|
||||
@@ -142,7 +123,7 @@ namespace Emby.Naming.Video
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension))
|
||||
if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions))
|
||||
{
|
||||
return videos;
|
||||
}
|
||||
@@ -153,9 +134,37 @@ namespace Emby.Naming.Video
|
||||
}
|
||||
}
|
||||
|
||||
var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString());
|
||||
if (videos.Count > 1)
|
||||
{
|
||||
var groups = videos.GroupBy(x => ResolutionRegex().IsMatch(x.Files[0].FileNameWithoutExtension)).ToList();
|
||||
videos.Clear();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Key)
|
||||
{
|
||||
videos.InsertRange(0, group
|
||||
.OrderByDescending(x => ResolutionRegex().Match(x.Files[0].FileNameWithoutExtension.ToString()).Value, new AlphanumericComparator())
|
||||
.ThenBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
|
||||
}
|
||||
else
|
||||
{
|
||||
videos.AddRange(group.OrderBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [organized];
|
||||
primary ??= videos[0];
|
||||
videos.Remove(primary);
|
||||
|
||||
var list = new List<VideoInfo>
|
||||
{
|
||||
primary
|
||||
};
|
||||
|
||||
list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray();
|
||||
list[0].Name = folderName.ToString();
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos)
|
||||
@@ -177,7 +186,7 @@ namespace Emby.Naming.Video
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename)
|
||||
private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions)
|
||||
{
|
||||
if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -191,7 +200,7 @@ namespace Emby.Naming.Video
|
||||
}
|
||||
|
||||
// There are no span overloads for regex unfortunately
|
||||
if (CleanStringParser.TryClean(testFilename.ToString(), _namingOptions.CleanStringRegexes, out var cleanName))
|
||||
if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName))
|
||||
{
|
||||
testFilename = cleanName.AsSpan().Trim();
|
||||
}
|
||||
@@ -199,117 +208,7 @@ namespace Emby.Naming.Video
|
||||
// The CleanStringParser should have removed common keywords etc.
|
||||
return testFilename.IsEmpty
|
||||
|| testFilename[0] == '-'
|
||||
|| testFilename[0] == '_'
|
||||
|| testFilename[0] == '.'
|
||||
|| CheckMultiVersionRegex().IsMatch(testFilename);
|
||||
}
|
||||
|
||||
private List<VideoInfo> GetEpisodesGroupedByVersion(List<VideoInfo> videos)
|
||||
{
|
||||
if (videos.Count < 2)
|
||||
{
|
||||
return videos;
|
||||
}
|
||||
|
||||
var result = new List<VideoInfo>();
|
||||
var groups = new Dictionary<string, List<VideoInfo>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < videos.Count; i++)
|
||||
{
|
||||
var video = videos[i];
|
||||
var episodeResult = _episodePathParser.Parse(video.Files[0].Path, false);
|
||||
string? key = null;
|
||||
if (episodeResult.Success)
|
||||
{
|
||||
if (episodeResult.IsByDate
|
||||
&& episodeResult.Year.HasValue
|
||||
&& episodeResult.Month.HasValue
|
||||
&& episodeResult.Day.HasValue)
|
||||
{
|
||||
key = FormattableString.Invariant(
|
||||
$"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}");
|
||||
}
|
||||
else if (episodeResult.EpisodeNumber.HasValue)
|
||||
{
|
||||
key = FormattableString.Invariant(
|
||||
$"S{episodeResult.SeasonNumber ?? 0}E{episodeResult.EpisodeNumber.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
result.Add(video);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groups.TryGetValue(key, out var group))
|
||||
{
|
||||
group = [];
|
||||
groups[key] = group;
|
||||
}
|
||||
|
||||
group.Add(video);
|
||||
}
|
||||
|
||||
foreach (var group in groups.Values)
|
||||
{
|
||||
if (group.Count == 1)
|
||||
{
|
||||
result.Add(group[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(OrganizeAlternateVersions(group));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static VideoInfo OrganizeAlternateVersions(
|
||||
List<VideoInfo> videos,
|
||||
VideoInfo? primaryOverride = null,
|
||||
string? nameOverride = null)
|
||||
{
|
||||
if (videos.Count > 1)
|
||||
{
|
||||
var groups = videos
|
||||
.Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x))
|
||||
.Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value))
|
||||
.GroupBy(x => x.resolutionMatch.Success)
|
||||
.ToList();
|
||||
|
||||
videos = [];
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Key)
|
||||
{
|
||||
videos.InsertRange(0, group
|
||||
.OrderByDescending(x => x.resolutionMatch.Value, _numericOrdinalComparer)
|
||||
.ThenBy(x => x.filename, _numericOrdinalComparer)
|
||||
.Select(x => x.value));
|
||||
}
|
||||
else
|
||||
{
|
||||
videos.AddRange(group.OrderBy(x => x.filename, _numericOrdinalComparer).Select(x => x.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer a stacked entry (more than one part) as primary
|
||||
var primary = primaryOverride
|
||||
?? videos.FirstOrDefault(v => v.Files.Count > 1)
|
||||
?? videos[0];
|
||||
videos.Remove(primary);
|
||||
|
||||
primary.AlternateVersions = videos;
|
||||
|
||||
if (nameOverride is not null)
|
||||
{
|
||||
primary.Name = nameOverride;
|
||||
}
|
||||
|
||||
return primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -90,7 +90,6 @@ namespace Emby.Server.Implementations.AppBase
|
||||
CreateAndCheckMarker(ProgramDataPath, "data");
|
||||
CreateAndCheckMarker(CachePath, "cache");
|
||||
CreateAndCheckMarker(DataPath, "data");
|
||||
CreateCacheDirTag(CachePath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -101,26 +100,6 @@ namespace Emby.Server.Implementations.AppBase
|
||||
CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CACHEDIR.TAG file in the specified directory per the Cache Directory Tagging specification.
|
||||
/// This signals to backup tools (e.g. Restic, Borg) that the directory contains cached data
|
||||
/// and can be excluded from backups.
|
||||
/// </summary>
|
||||
/// <param name="path">The cache directory path.</param>
|
||||
internal static void CreateCacheDirTag(string path)
|
||||
{
|
||||
var tagPath = Path.Combine(path, "CACHEDIR.TAG");
|
||||
if (!File.Exists(tagPath))
|
||||
{
|
||||
File.WriteAllText(
|
||||
tagPath,
|
||||
"Signature: 8a477f597d28d172789f06886806bc55\n"
|
||||
+ "# This file is a cache directory tag created by Jellyfin.\n"
|
||||
+ "# For information about cache directory tags, see:\n"
|
||||
+ "#\thttps://bford.info/cachedir/\n");
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetMarkers(string path, bool recursive = false)
|
||||
{
|
||||
return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
|
||||
|
||||
@@ -132,7 +132,7 @@ namespace Emby.Server.Implementations.AppBase
|
||||
}
|
||||
else
|
||||
{
|
||||
_configurationFactories = [.. _configurationFactories, factory];
|
||||
_configurationFactories = [.._configurationFactories, factory];
|
||||
}
|
||||
|
||||
_configurationStores = _configurationFactories
|
||||
@@ -228,7 +228,6 @@ namespace Emby.Server.Implementations.AppBase
|
||||
Logger.LogInformation("Setting cache path: {Path}", cachePath);
|
||||
((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath;
|
||||
CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache");
|
||||
BaseApplicationPaths.CreateCacheDirTag(cachePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,7 +14,6 @@ using System.Reflection;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Naming.Common;
|
||||
using Emby.Naming.Video;
|
||||
using Emby.Photos;
|
||||
using Emby.Server.Implementations.Chapters;
|
||||
using Emby.Server.Implementations.Collections;
|
||||
@@ -26,8 +25,6 @@ using Emby.Server.Implementations.Dto;
|
||||
using Emby.Server.Implementations.HttpServer.Security;
|
||||
using Emby.Server.Implementations.IO;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using Emby.Server.Implementations.Library.Search;
|
||||
using Emby.Server.Implementations.Library.SimilarItems;
|
||||
using Emby.Server.Implementations.Localization;
|
||||
using Emby.Server.Implementations.Playlists;
|
||||
using Emby.Server.Implementations.Plugins;
|
||||
@@ -39,8 +36,6 @@ using Emby.Server.Implementations.SyncPlay;
|
||||
using Emby.Server.Implementations.TV;
|
||||
using Emby.Server.Implementations.Updates;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Drawing;
|
||||
using Jellyfin.MediaEncoding.Hls.Playlist;
|
||||
using Jellyfin.Networking.Manager;
|
||||
@@ -95,16 +90,9 @@ using MediaBrowser.Model.Net;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using MediaBrowser.Model.System;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using MediaBrowser.Providers.Books;
|
||||
using MediaBrowser.Providers.Books.ComicBookInfo;
|
||||
using MediaBrowser.Providers.Books.ComicInfo;
|
||||
using MediaBrowser.Providers.Lyric;
|
||||
using MediaBrowser.Providers.Manager;
|
||||
using MediaBrowser.Providers.Plugins.ListenBrainz;
|
||||
using MediaBrowser.Providers.Plugins.ListenBrainz.Api;
|
||||
using MediaBrowser.Providers.Plugins.Tmdb;
|
||||
using MediaBrowser.Providers.Plugins.Tmdb.Movies;
|
||||
using MediaBrowser.Providers.Plugins.Tmdb.TV;
|
||||
using MediaBrowser.Providers.Subtitles;
|
||||
using MediaBrowser.XbmcMetadata.Providers;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -178,6 +166,8 @@ namespace Emby.Server.Implementations
|
||||
ConfigurationManager.Configuration,
|
||||
ApplicationPaths.PluginsPath,
|
||||
ApplicationVersion);
|
||||
|
||||
_disposableParts.Add(_pluginManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -419,8 +409,6 @@ namespace Emby.Server.Implementations
|
||||
{
|
||||
Logger.LogInformation("Running startup tasks");
|
||||
|
||||
EnsureStartupWizardIntegrity();
|
||||
|
||||
Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false));
|
||||
|
||||
ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
|
||||
@@ -440,24 +428,6 @@ namespace Emby.Server.Implementations
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void EnsureStartupWizardIntegrity()
|
||||
{
|
||||
if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers()
|
||||
.Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password));
|
||||
|
||||
if (hasConfiguredAdministrator)
|
||||
{
|
||||
Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable.");
|
||||
ConfigurationManager.Configuration.IsStartupWizardCompleted = true;
|
||||
ConfigurationManager.SaveConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Init(IServiceCollection serviceCollection)
|
||||
{
|
||||
@@ -515,19 +485,6 @@ namespace Emby.Server.Implementations
|
||||
serviceCollection.AddScoped<ISystemManager, SystemManager>();
|
||||
|
||||
serviceCollection.AddSingleton<TmdbClientManager>();
|
||||
serviceCollection.AddSingleton<TmdbMovieSimilarProvider>();
|
||||
serviceCollection.AddSingleton<TmdbSeriesSimilarProvider>();
|
||||
|
||||
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
|
||||
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
|
||||
|
||||
// register the generic local metadata provider for comic files
|
||||
serviceCollection.AddSingleton<ComicProvider>();
|
||||
|
||||
// register the actual implementations of the local metadata provider for comic files
|
||||
serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
|
||||
serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
|
||||
serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
|
||||
|
||||
serviceCollection.AddSingleton(NetManager);
|
||||
|
||||
@@ -550,13 +507,7 @@ namespace Emby.Server.Implementations
|
||||
|
||||
serviceCollection.AddSingleton<IUserDataManager, UserDataManager>();
|
||||
|
||||
serviceCollection.AddSingleton<BaseItemRepository>();
|
||||
serviceCollection.AddSingleton<IItemRepository>(sp => sp.GetRequiredService<BaseItemRepository>());
|
||||
serviceCollection.AddSingleton<IItemQueryHelpers>(sp => sp.GetRequiredService<BaseItemRepository>());
|
||||
serviceCollection.AddSingleton<IItemPersistenceService, ItemPersistenceService>();
|
||||
serviceCollection.AddSingleton<INextUpService, NextUpService>();
|
||||
serviceCollection.AddSingleton<IItemCountService, ItemCountService>();
|
||||
serviceCollection.AddSingleton<ILinkedChildrenService, LinkedChildrenService>();
|
||||
serviceCollection.AddSingleton<IItemRepository, BaseItemRepository>();
|
||||
serviceCollection.AddSingleton<IPeopleRepository, PeopleRepository>();
|
||||
serviceCollection.AddSingleton<IChapterRepository, ChapterRepository>();
|
||||
serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>();
|
||||
@@ -573,20 +524,14 @@ namespace Emby.Server.Implementations
|
||||
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
|
||||
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
|
||||
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
|
||||
serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
|
||||
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
|
||||
serviceCollection.AddSingleton<NamingOptions>();
|
||||
serviceCollection.AddSingleton<VideoListResolver>();
|
||||
|
||||
serviceCollection.AddSingleton<IMusicManager, MusicManager>();
|
||||
|
||||
serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
|
||||
serviceCollection.AddSingleton<DotIgnoreIgnoreRule>();
|
||||
|
||||
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
|
||||
|
||||
serviceCollection.AddSingleton<ISearchManager, SearchManager>();
|
||||
serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
|
||||
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
|
||||
|
||||
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
|
||||
|
||||
@@ -696,7 +641,6 @@ namespace Emby.Server.Implementations
|
||||
BaseItem.ConfigurationManager = ConfigurationManager;
|
||||
BaseItem.FileSystem = Resolve<IFileSystem>();
|
||||
BaseItem.ItemRepository = Resolve<IItemRepository>();
|
||||
BaseItem.ItemCountService = Resolve<IItemCountService>();
|
||||
BaseItem.LibraryManager = Resolve<ILibraryManager>();
|
||||
BaseItem.LocalizationManager = Resolve<ILocalizationManager>();
|
||||
BaseItem.Logger = Resolve<ILogger<BaseItem>>();
|
||||
@@ -743,9 +687,6 @@ namespace Emby.Server.Implementations
|
||||
GetExports<IExternalUrlProvider>());
|
||||
|
||||
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
|
||||
|
||||
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
|
||||
Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -987,9 +928,8 @@ namespace Emby.Server.Implementations
|
||||
/// <inheritdoc/>
|
||||
public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null)
|
||||
{
|
||||
// If the smartAPI isn't already a complete URL then treat it as a host or ip.
|
||||
if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
// If the smartAPI doesn't start with http then treat it as a host or ip.
|
||||
if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return hostname.TrimEnd('/');
|
||||
}
|
||||
@@ -1066,8 +1006,6 @@ namespace Emby.Server.Implementations
|
||||
}
|
||||
|
||||
_disposableParts.Clear();
|
||||
|
||||
_pluginManager?.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
@@ -7,7 +7,6 @@ using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Chapters;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
@@ -129,7 +128,7 @@ public class ChapterManager : IChapterManager
|
||||
|
||||
var averageChapterDuration = GetAverageDurationBetweenChapters(chapters);
|
||||
var threshold = TimeSpan.FromSeconds(1).Ticks;
|
||||
if (chapters.Count >= 2 && averageChapterDuration < threshold)
|
||||
if (averageChapterDuration < threshold)
|
||||
{
|
||||
_logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold);
|
||||
extractImages = false;
|
||||
@@ -233,21 +232,11 @@ public class ChapterManager : IChapterManager
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Supports(BaseItem item)
|
||||
=> item is Video or Audio;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SaveChapters(BaseItem item, IReadOnlyList<ChapterInfo> chapters)
|
||||
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
|
||||
{
|
||||
if (!Supports(item))
|
||||
{
|
||||
_logger.LogWarning("Attempted to save chapters for unsupported item type {Type}: {Name} ({Id})", item.GetType().Name, item.Name, item.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any chapters that are outside of the runtime of the item
|
||||
var validChapters = chapters.Where(c => c.StartPositionTicks < item.RunTimeTicks).ToList();
|
||||
_chapterRepository.SaveChapters(item.Id, validChapters);
|
||||
// Remove any chapters that are outside of the runtime of the video
|
||||
var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList();
|
||||
_chapterRepository.SaveChapters(video.Id, validChapters);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -4,15 +4,12 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Collections;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@@ -32,7 +29,6 @@ namespace Emby.Server.Implementations.Collections
|
||||
private readonly ILibraryMonitor _iLibraryMonitor;
|
||||
private readonly ILogger<CollectionManager> _logger;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly ILinkedChildrenService _linkedChildrenService;
|
||||
private readonly ILocalizationManager _localizationManager;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
|
||||
@@ -46,7 +42,6 @@ namespace Emby.Server.Implementations.Collections
|
||||
/// <param name="iLibraryMonitor">The library monitor.</param>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="providerManager">The provider manager.</param>
|
||||
/// <param name="linkedChildrenService">The linked children service.</param>
|
||||
public CollectionManager(
|
||||
ILibraryManager libraryManager,
|
||||
IApplicationPaths appPaths,
|
||||
@@ -54,15 +49,13 @@ namespace Emby.Server.Implementations.Collections
|
||||
IFileSystem fileSystem,
|
||||
ILibraryMonitor iLibraryMonitor,
|
||||
ILoggerFactory loggerFactory,
|
||||
IProviderManager providerManager,
|
||||
ILinkedChildrenService linkedChildrenService)
|
||||
IProviderManager providerManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_fileSystem = fileSystem;
|
||||
_iLibraryMonitor = iLibraryMonitor;
|
||||
_logger = loggerFactory.CreateLogger<CollectionManager>();
|
||||
_providerManager = providerManager;
|
||||
_linkedChildrenService = linkedChildrenService;
|
||||
_localizationManager = localizationManager;
|
||||
_appPaths = appPaths;
|
||||
}
|
||||
@@ -107,8 +100,7 @@ namespace Emby.Server.Implementations.Collections
|
||||
SaveLocalMetadata = true
|
||||
};
|
||||
|
||||
// This names a library for the whole server, so ignore the requesting client's language.
|
||||
var name = _localizationManager.GetServerLocalizedString("Collections");
|
||||
var name = _localizationManager.GetLocalizedString("Collections");
|
||||
|
||||
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
|
||||
|
||||
@@ -128,22 +120,6 @@ namespace Emby.Server.Implementations.Collections
|
||||
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
if (itemId.IsEmpty())
|
||||
{
|
||||
return Enumerable.Empty<BoxSet>();
|
||||
}
|
||||
|
||||
return _linkedChildrenService
|
||||
.GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet)
|
||||
.Select(parentId => _libraryManager.GetItemById<BoxSet>(parentId, user))
|
||||
.OfType<BoxSet>();
|
||||
}
|
||||
|
||||
private IEnumerable<BoxSet> GetCollections(User user)
|
||||
{
|
||||
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();
|
||||
@@ -296,7 +272,7 @@ namespace Emby.Server.Implementations.Collections
|
||||
{
|
||||
var childItem = _libraryManager.GetItemById(guidId);
|
||||
|
||||
var child = collection.LinkedChildren.FirstOrDefault(i => i.ItemId.HasValue && i.ItemId.Value.Equals(guidId));
|
||||
var child = collection.LinkedChildren.FirstOrDefault(i => (i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)) || (childItem is not null && string.Equals(childItem.Path, i.Path, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (child is null)
|
||||
{
|
||||
@@ -366,7 +342,7 @@ namespace Emby.Server.Implementations.Collections
|
||||
// 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)
|
||||
{
|
||||
foreach (var childId in _libraryManager.GetLocalAlternateVersionIds(video))
|
||||
foreach (var childId in video.GetLocalAlternateVersionIds())
|
||||
{
|
||||
if (!results.ContainsKey(childId))
|
||||
{
|
||||
|
||||
@@ -39,24 +39,22 @@ namespace Emby.Server.Implementations.Cryptography
|
||||
{
|
||||
if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
|
||||
{
|
||||
var iterations = GetIterationsParameter(hash);
|
||||
return hash.Hash.SequenceEqual(
|
||||
Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
hash.Salt,
|
||||
iterations,
|
||||
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
|
||||
HashAlgorithmName.SHA1,
|
||||
32));
|
||||
}
|
||||
|
||||
if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
|
||||
{
|
||||
var iterations = GetIterationsParameter(hash);
|
||||
return hash.Hash.SequenceEqual(
|
||||
Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
hash.Salt,
|
||||
iterations,
|
||||
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
|
||||
HashAlgorithmName.SHA512,
|
||||
DefaultOutputLength));
|
||||
}
|
||||
@@ -64,27 +62,6 @@ namespace Emby.Server.Implementations.Cryptography
|
||||
throw new NotSupportedException($"Can't verify hash with id: {hash.Id}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts and validates the iterations parameter from a password hash.
|
||||
/// </summary>
|
||||
/// <param name="hash">The password hash containing parameters.</param>
|
||||
/// <returns>The number of iterations.</returns>
|
||||
/// <exception cref="FormatException">Thrown when iterations parameter is missing or invalid.</exception>
|
||||
private static int GetIterationsParameter(PasswordHash hash)
|
||||
{
|
||||
if (!hash.Parameters.TryGetValue("iterations", out var iterationsStr))
|
||||
{
|
||||
throw new FormatException($"Password hash with id '{hash.Id}' is missing required 'iterations' parameter.");
|
||||
}
|
||||
|
||||
if (!int.TryParse(iterationsStr, CultureInfo.InvariantCulture, out var iterations))
|
||||
{
|
||||
throw new FormatException($"Password hash with id '{hash.Id}' has invalid 'iterations' parameter: '{iterationsStr}'.");
|
||||
}
|
||||
|
||||
return iterations;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] GenerateSalt()
|
||||
=> GenerateSalt(DefaultSaltLength);
|
||||
|
||||
@@ -5,12 +5,10 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -37,11 +35,7 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
||||
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var deadItemsProgress = new Progress<double>(val => progress.Report(val * 0.8));
|
||||
await CleanDeadItems(cancellationToken, deadItemsProgress).ConfigureAwait(false);
|
||||
|
||||
var playlistProgress = new Progress<double>(val => progress.Report(80 + (val * 0.2)));
|
||||
await CleanOrphanedFilePlaylistsAsync(cancellationToken, playlistProgress).ConfigureAwait(false);
|
||||
await CleanDeadItems(cancellationToken, progress).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress)
|
||||
@@ -122,32 +116,4 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private async Task CleanOrphanedFilePlaylistsAsync(CancellationToken cancellationToken, IProgress<double> progress)
|
||||
{
|
||||
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Playlist],
|
||||
Recursive = true
|
||||
}).OfType<Playlist>().ToList();
|
||||
|
||||
var numComplete = 0;
|
||||
var numItems = Math.Max(playlists.Count, 1);
|
||||
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (playlist.IsFile && !File.Exists(playlist.Path))
|
||||
{
|
||||
_logger.LogInformation("Removing file-based playlist {Name} because source file {Path} no longer exists", playlist.Name, playlist.Path);
|
||||
_libraryManager.DeleteItem(playlist, new DeleteOptions { DeleteFileLocation = false });
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
progress.Report((double)numComplete / numItems * 100);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -8,9 +10,6 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Devices
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides the persistent unique identifier of this server installation.
|
||||
/// </summary>
|
||||
public class DeviceId
|
||||
{
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
@@ -19,20 +18,12 @@ namespace Emby.Server.Implementations.Devices
|
||||
|
||||
private string? _id;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeviceId"/> class.
|
||||
/// </summary>
|
||||
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param>
|
||||
public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
|
||||
{
|
||||
_appPaths = appPaths;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the device id, loading it from disk or generating and persisting a new one if none exists.
|
||||
/// </summary>
|
||||
public string Value => _id ??= GetDeviceId();
|
||||
|
||||
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
|
||||
|
||||
@@ -71,8 +71,6 @@ namespace Emby.Server.Implementations.Dto
|
||||
{
|
||||
BaseItemKind.Person, [
|
||||
BaseItemKind.Audio,
|
||||
BaseItemKind.AudioBook,
|
||||
BaseItemKind.Book,
|
||||
BaseItemKind.Episode,
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.LiveTvProgram,
|
||||
@@ -155,139 +153,17 @@ namespace Emby.Server.Implementations.Dto
|
||||
private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<BaseItemDto> GetBaseItemDtos(
|
||||
IReadOnlyList<BaseItem> items,
|
||||
DtoOptions options,
|
||||
User? user = null,
|
||||
BaseItem? owner = null,
|
||||
bool skipVisibilityCheck = false)
|
||||
public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null)
|
||||
{
|
||||
var accessibleItems = skipVisibilityCheck || user is null ? items : items.Where(x => x.IsVisible(user)).ToList();
|
||||
var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList();
|
||||
var returnItems = new BaseItemDto[accessibleItems.Count];
|
||||
List<(BaseItem, BaseItemDto)>? programTuples = null;
|
||||
List<(BaseItemDto, LiveTvChannel)>? channelTuples = null;
|
||||
|
||||
// Batch-fetch user data for all items
|
||||
Dictionary<Guid, UserItemData>? userDataBatch = null;
|
||||
IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null;
|
||||
if (user is not null && options.EnableUserData)
|
||||
{
|
||||
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
|
||||
|
||||
// For items with alternate versions, the most recently played version drives resume.
|
||||
resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user);
|
||||
}
|
||||
|
||||
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
|
||||
List<Folder>? allCollectionFolders = null;
|
||||
if (user is not null && options.ContainsField(ItemFields.CanDelete))
|
||||
{
|
||||
allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();
|
||||
}
|
||||
|
||||
// Batch-fetch by-name item counts to avoid N+1 queries
|
||||
Dictionary<Guid, ItemCounts>? itemCountsBatch = null;
|
||||
if (options.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
itemCountsBatch = GetItemCountsBatch(accessibleItems, user);
|
||||
}
|
||||
|
||||
// Batch-fetch child counts for all folders to avoid N+1 queries
|
||||
Dictionary<Guid, int>? childCountBatch = null;
|
||||
if (options.ContainsField(ItemFields.ChildCount))
|
||||
{
|
||||
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
|
||||
if (folderIds.Count > 0)
|
||||
{
|
||||
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch played/total counts for all folders to avoid N+1 queries
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null;
|
||||
if (user is not null && options.EnableUserData)
|
||||
{
|
||||
var folderIds = accessibleItems.OfType<Folder>()
|
||||
.Where(f => f.SupportsUserDataFromChildren && (f.SupportsPlayedStatus || options.ContainsField(ItemFields.RecursiveItemCount)))
|
||||
.Select(f => f.Id).ToList();
|
||||
if (folderIds.Count > 0)
|
||||
{
|
||||
playedCountBatch = _libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch MusicArtist lookups across all items to avoid N+1 queries.
|
||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null;
|
||||
var artistNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var item in accessibleItems)
|
||||
{
|
||||
if (item is IHasArtist hasArtist)
|
||||
{
|
||||
foreach (var name in hasArtist.Artists)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
artistNames.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||
{
|
||||
foreach (var name in hasAlbumArtist.AlbumArtists)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
artistNames.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artistNames.Count > 0)
|
||||
{
|
||||
artistsBatch = _libraryManager.GetArtists(artistNames.ToArray());
|
||||
}
|
||||
|
||||
// Batch-fetch people across all items to avoid one GetPeople query per item.
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null;
|
||||
if (options.ContainsField(ItemFields.People))
|
||||
{
|
||||
var peopleItemIds = accessibleItems.Where(i => i.SupportsPeople).Select(i => i.Id).ToList();
|
||||
if (peopleItemIds.Count > 0)
|
||||
{
|
||||
peopleBatch = _libraryManager.GetPeopleByItems(peopleItemIds);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-detect which videos own alternate versions to avoid the per-item alternate-version
|
||||
// queries in MediaSourceCount. Videos absent from this set have a single media source.
|
||||
IReadOnlySet<Guid>? alternateVersionItemIds = null;
|
||||
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||
{
|
||||
var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList();
|
||||
if (versionItemIds.Count > 0)
|
||||
{
|
||||
alternateVersionItemIds = _libraryManager.GetItemIdsWithAlternateVersions(versionItemIds);
|
||||
}
|
||||
}
|
||||
|
||||
for (int index = 0; index < accessibleItems.Count; index++)
|
||||
{
|
||||
var item = accessibleItems[index];
|
||||
var dto = GetBaseItemDtoInternal(
|
||||
item,
|
||||
options,
|
||||
user,
|
||||
owner,
|
||||
userDataBatch?.GetValueOrDefault(item.Id),
|
||||
allCollectionFolders,
|
||||
childCountBatch,
|
||||
playedCountBatch,
|
||||
artistsBatch,
|
||||
resumeDataBatch?.GetValueOrDefault(item.Id),
|
||||
peopleBatch,
|
||||
alternateVersionItemIds);
|
||||
var dto = GetBaseItemDtoInternal(item, options, user, owner);
|
||||
|
||||
if (item is LiveTvChannel tvChannel)
|
||||
{
|
||||
@@ -300,7 +176,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
SetItemByNameInfo(dto, user, itemCountsBatch);
|
||||
SetItemByNameInfo(dto, user);
|
||||
}
|
||||
|
||||
returnItems[index] = dto;
|
||||
@@ -321,7 +197,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
|
||||
{
|
||||
var dto = GetBaseItemDtoInternal(item, options, user, owner, null);
|
||||
var dto = GetBaseItemDtoInternal(item, options, user, owner);
|
||||
if (item is LiveTvChannel tvChannel)
|
||||
{
|
||||
LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user);
|
||||
@@ -339,19 +215,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
return dto;
|
||||
}
|
||||
|
||||
private BaseItemDto GetBaseItemDtoInternal(
|
||||
BaseItem item,
|
||||
DtoOptions options,
|
||||
User? user = null,
|
||||
BaseItem? owner = null,
|
||||
UserItemData? userData = null,
|
||||
List<Folder>? allCollectionFolders = null,
|
||||
Dictionary<Guid, int>? childCountBatch = null,
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
|
||||
VersionResumeData? resumeData = null,
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null,
|
||||
IReadOnlySet<Guid>? alternateVersionItemIds = null)
|
||||
private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
|
||||
{
|
||||
var dto = new BaseItemDto
|
||||
{
|
||||
@@ -365,15 +229,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.People))
|
||||
{
|
||||
IReadOnlyList<PersonInfo>? prefetchedPeople = null;
|
||||
if (peopleBatch is not null)
|
||||
{
|
||||
// The batch omits items with no people, so a miss means "no people",
|
||||
// not "not fetched". Use an empty list to skip the per-item query.
|
||||
prefetchedPeople = peopleBatch.GetValueOrDefault(item.Id) ?? [];
|
||||
}
|
||||
|
||||
AttachPeople(dto, item, user, prefetchedPeople);
|
||||
AttachPeople(dto, item, user);
|
||||
}
|
||||
|
||||
if (options.ContainsField(ItemFields.PrimaryImageAspectRatio))
|
||||
@@ -396,15 +252,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
AttachUserSpecificInfo(
|
||||
dto,
|
||||
item,
|
||||
user,
|
||||
options,
|
||||
userData,
|
||||
childCountBatch,
|
||||
playedCountBatch,
|
||||
resumeData);
|
||||
AttachUserSpecificInfo(dto, item, user, options);
|
||||
}
|
||||
|
||||
if (item is IHasMediaSources
|
||||
@@ -420,15 +268,13 @@ namespace Emby.Server.Implementations.Dto
|
||||
AttachStudios(dto, item);
|
||||
}
|
||||
|
||||
AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds);
|
||||
AttachBasicFields(dto, item, owner, options);
|
||||
|
||||
if (options.ContainsField(ItemFields.CanDelete))
|
||||
{
|
||||
dto.CanDelete = user is null
|
||||
? item.CanDelete()
|
||||
: allCollectionFolders is not null
|
||||
? item.CanDelete(user, allCollectionFolders)
|
||||
: item.CanDelete(user);
|
||||
: item.CanDelete(user);
|
||||
}
|
||||
|
||||
if (options.ContainsField(ItemFields.CanDownload))
|
||||
@@ -525,36 +371,44 @@ namespace Emby.Server.Implementations.Dto
|
||||
return dto;
|
||||
}
|
||||
|
||||
private Dictionary<Guid, ItemCounts> GetItemCountsBatch(IReadOnlyList<BaseItem> items, User? user)
|
||||
{
|
||||
var result = new Dictionary<Guid, ItemCounts>();
|
||||
|
||||
foreach (var group in items.GroupBy(item => item.GetBaseItemKind()))
|
||||
{
|
||||
if (!_relatedItemKinds.TryGetValue(group.Key, out var relatedItemKinds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ids = group.Select(item => item.Id).ToArray();
|
||||
foreach (var (id, counts) in _libraryManager.GetItemCountsForNameItems(group.Key, ids, relatedItemKinds, user))
|
||||
{
|
||||
result[id] = counts;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void SetItemByNameInfo(BaseItemDto dto, User? user, IReadOnlyDictionary<Guid, ItemCounts>? prefetchedCounts = null)
|
||||
private void SetItemByNameInfo(BaseItemDto dto, User? user)
|
||||
{
|
||||
if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var counts = prefetchedCounts?.GetValueOrDefault(dto.Id)
|
||||
?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user);
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
Recursive = true,
|
||||
DtoOptions = new DtoOptions(false) { EnableImages = false },
|
||||
IncludeItemTypes = relatedItemKinds
|
||||
};
|
||||
|
||||
switch (dto.Type)
|
||||
{
|
||||
case BaseItemKind.Genre:
|
||||
case BaseItemKind.MusicGenre:
|
||||
query.GenreIds = [dto.Id];
|
||||
break;
|
||||
case BaseItemKind.MusicArtist:
|
||||
query.ArtistIds = [dto.Id];
|
||||
break;
|
||||
case BaseItemKind.Person:
|
||||
query.PersonIds = [dto.Id];
|
||||
break;
|
||||
case BaseItemKind.Studio:
|
||||
query.StudioIds = [dto.Id];
|
||||
break;
|
||||
case BaseItemKind.Year
|
||||
when int.TryParse(dto.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year):
|
||||
query.Years = [year];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
var counts = _libraryManager.GetItemCounts(query);
|
||||
|
||||
dto.AlbumCount = counts.AlbumCount;
|
||||
dto.ArtistCount = counts.ArtistCount;
|
||||
@@ -604,15 +458,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
/// <summary>
|
||||
/// Attaches the user specific info.
|
||||
/// </summary>
|
||||
private void AttachUserSpecificInfo(
|
||||
BaseItemDto dto,
|
||||
BaseItem item,
|
||||
User user,
|
||||
DtoOptions options,
|
||||
UserItemData? userData = null,
|
||||
Dictionary<Guid, int>? childCountBatch = null,
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||
VersionResumeData? resumeData = null)
|
||||
private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options)
|
||||
{
|
||||
if (item.IsFolder)
|
||||
{
|
||||
@@ -620,19 +466,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.EnableUserData)
|
||||
{
|
||||
if (userData is not null)
|
||||
{
|
||||
// Use pre-fetched user data
|
||||
dto.UserData = GetUserItemDataDto(userData, item.Id);
|
||||
(int Played, int Total)? precomputed = playedCountBatch is not null
|
||||
&& playedCountBatch.TryGetValue(item.Id, out var counts) ? counts : null;
|
||||
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options, precomputed);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to individual fetch
|
||||
dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options);
|
||||
}
|
||||
dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options);
|
||||
}
|
||||
|
||||
if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library)
|
||||
@@ -640,11 +474,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
// For these types we can try to optimize and assume these values will be equal
|
||||
if (item is MusicAlbum || item is Season || item is Playlist)
|
||||
{
|
||||
if (dto.RecursiveItemCount > 0)
|
||||
{
|
||||
dto.ChildCount = dto.RecursiveItemCount;
|
||||
}
|
||||
|
||||
dto.ChildCount = dto.RecursiveItemCount;
|
||||
var folderChildCount = folder.LinkedChildren.Length;
|
||||
// The default is an empty array, so we can't reliably use the count when it's empty
|
||||
if (folderChildCount > 0)
|
||||
@@ -655,7 +485,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.ChildCount))
|
||||
{
|
||||
dto.ChildCount ??= GetChildCount(folder, user, childCountBatch);
|
||||
dto.ChildCount ??= GetChildCount(folder, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,20 +503,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
{
|
||||
if (options.EnableUserData)
|
||||
{
|
||||
if (userData is not null)
|
||||
{
|
||||
// Use pre-fetched user data
|
||||
dto.UserData = GetUserItemDataDto(userData, item.Id);
|
||||
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
|
||||
|
||||
// For items with alternate versions, the most recently played version drives resume.
|
||||
resumeData?.ApplyTo(dto.UserData);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to individual fetch
|
||||
dto.UserData = _userDataRepository.GetUserDataDto(item, user);
|
||||
}
|
||||
dto.UserData = _userDataRepository.GetUserDataDto(item, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,25 +513,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
}
|
||||
}
|
||||
|
||||
private static UserItemDataDto GetUserItemDataDto(UserItemData data, Guid itemId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
return new UserItemDataDto
|
||||
{
|
||||
IsFavorite = data.IsFavorite,
|
||||
Likes = data.Likes,
|
||||
PlaybackPositionTicks = data.PlaybackPositionTicks,
|
||||
PlayCount = data.PlayCount,
|
||||
Rating = data.Rating,
|
||||
Played = data.Played,
|
||||
LastPlayedDate = data.LastPlayedDate,
|
||||
ItemId = itemId,
|
||||
Key = data.Key
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch)
|
||||
private static int GetChildCount(Folder folder, User user)
|
||||
{
|
||||
// Right now this is too slow to calculate for top level folders on a per-user basis
|
||||
// Just return something so that apps that are expecting a value won't think the folders are empty
|
||||
@@ -723,14 +522,6 @@ namespace Emby.Server.Implementations.Dto
|
||||
return Random.Shared.Next(1, 10);
|
||||
}
|
||||
|
||||
// Use pre-fetched batch data if available
|
||||
if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count))
|
||||
{
|
||||
return count;
|
||||
}
|
||||
|
||||
// Only reached when no batch was computed: the batch holds an entry for every folder it
|
||||
// was asked about, zero included.
|
||||
return folder.GetChildCount(user);
|
||||
}
|
||||
|
||||
@@ -811,18 +602,12 @@ namespace Emby.Server.Implementations.Dto
|
||||
/// <param name="dto">The dto.</param>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="user">The requesting user.</param>
|
||||
/// <param name="prefetchedPeople">People fetched in batch by the caller; when null the people are queried per item.</param>
|
||||
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null, IReadOnlyList<PersonInfo>? prefetchedPeople = null)
|
||||
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null)
|
||||
{
|
||||
// When rendering a page of items the caller batch-fetches people for every item up
|
||||
// front and passes them in, avoiding one GetPeople query per item. Fall back to the
|
||||
// per-item query for the single item path where no batch is available.
|
||||
var source = prefetchedPeople ?? _libraryManager.GetPeople(item);
|
||||
|
||||
// Ordering by person type to ensure actors and artists are at the front.
|
||||
// This is taking advantage of the fact that they both begin with A
|
||||
// This should be improved in the future
|
||||
var people = source.OrderBy(i => i.SortOrder ?? int.MaxValue)
|
||||
var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue)
|
||||
.ThenBy(i =>
|
||||
{
|
||||
if (i.IsType(PersonKind.Actor))
|
||||
@@ -1030,10 +815,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="owner">The owner.</param>
|
||||
/// <param name="options">The options.</param>
|
||||
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
|
||||
/// <param name="user">The user, for per-user values such as the accessible media source count.</param>
|
||||
/// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param>
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null)
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options)
|
||||
{
|
||||
if (options.ContainsField(ItemFields.DateCreated))
|
||||
{
|
||||
@@ -1157,14 +939,12 @@ namespace Emby.Server.Implementations.Dto
|
||||
dto.OriginalTitle = item.OriginalTitle;
|
||||
}
|
||||
|
||||
dto.OriginalLanguage = item.OriginalLanguage;
|
||||
|
||||
if (options.ContainsField(ItemFields.ParentId))
|
||||
{
|
||||
dto.ParentId = item.DisplayParentId;
|
||||
}
|
||||
|
||||
AddInheritedImages(dto, item, options, owner, artistsBatch);
|
||||
AddInheritedImages(dto, item, options, owner);
|
||||
|
||||
if (options.ContainsField(ItemFields.Path))
|
||||
{
|
||||
@@ -1239,15 +1019,6 @@ namespace Emby.Server.Implementations.Dto
|
||||
{
|
||||
dto.AlbumId = albumParent.Id;
|
||||
dto.AlbumPrimaryImageTag = GetTagAndFillBlurhash(dto, albumParent, ImageType.Primary);
|
||||
if (albumParent.LUFS.HasValue)
|
||||
{
|
||||
// -18 LUFS reference, same as ReplayGain 2.0, compatible with ReplayGain 1.0
|
||||
dto.AlbumNormalizationGain = -18f - albumParent.LUFS;
|
||||
}
|
||||
else if (albumParent.NormalizationGain.HasValue)
|
||||
{
|
||||
dto.AlbumNormalizationGain = albumParent.NormalizationGain;
|
||||
}
|
||||
}
|
||||
|
||||
// if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||
@@ -1280,8 +1051,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||
var artistsLookup = artistsBatch
|
||||
?? _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.ArtistItems = hasArtist.Artists
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
@@ -1315,8 +1085,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
// })
|
||||
// .ToList();
|
||||
|
||||
var albumArtistsLookup = artistsBatch
|
||||
?? _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
@@ -1347,30 +1116,18 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||
{
|
||||
// A video with no primary version and no alternate versions always has a single
|
||||
// media source. Only compute the count for videos that might have more: a primary
|
||||
// version, or membership in the batch's set of items that own alternate versions.
|
||||
// Without the batch we can't rule it out, so fall back to computing (the single-item
|
||||
// path). Everything else is the common case and keeps the default count of one.
|
||||
var mayHaveAlternateVersions = alternateVersionItemIds is null
|
||||
|| video.PrimaryVersionId.HasValue
|
||||
|| alternateVersionItemIds.Contains(video.Id);
|
||||
|
||||
if (mayHaveAlternateVersions)
|
||||
var mediaSourceCount = video.MediaSourceCount;
|
||||
if (mediaSourceCount != 1)
|
||||
{
|
||||
// Match the per-user filtering of the media sources: versions the user cannot
|
||||
// access are not selectable, so they must not count towards the badge either.
|
||||
var mediaSourceCount = user is null
|
||||
|| (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
|
||||
? video.MediaSourceCount
|
||||
: video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
|
||||
if (mediaSourceCount != 1)
|
||||
{
|
||||
dto.MediaSourceCount = mediaSourceCount;
|
||||
}
|
||||
dto.MediaSourceCount = mediaSourceCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.ContainsField(ItemFields.Chapters))
|
||||
{
|
||||
dto.Chapters = _chapterManager.GetChapters(item.Id).ToList();
|
||||
}
|
||||
|
||||
if (options.ContainsField(ItemFields.Trickplay))
|
||||
{
|
||||
var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
|
||||
@@ -1384,11 +1141,6 @@ namespace Emby.Server.Implementations.Dto
|
||||
dto.ExtraType = video.ExtraType;
|
||||
}
|
||||
|
||||
if (options.ContainsField(ItemFields.Chapters))
|
||||
{
|
||||
dto.Chapters = _chapterManager.GetChapters(item.Id).ToList();
|
||||
}
|
||||
|
||||
if (options.ContainsField(ItemFields.MediaStreams))
|
||||
{
|
||||
// Add VideoInfo
|
||||
@@ -1473,25 +1225,6 @@ namespace Emby.Server.Implementations.Dto
|
||||
}
|
||||
}
|
||||
|
||||
if (options.GetImageLimit(ImageType.Primary) > 0)
|
||||
{
|
||||
var episodeSeason = episode.Season;
|
||||
var seasonPrimaryTag = episodeSeason is not null
|
||||
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
|
||||
: null;
|
||||
|
||||
if (seasonPrimaryTag is not null)
|
||||
{
|
||||
dto.ParentPrimaryImageItemId = episodeSeason!.Id;
|
||||
dto.ParentPrimaryImageTag = seasonPrimaryTag;
|
||||
}
|
||||
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
|
||||
{
|
||||
dto.ParentPrimaryImageItemId = episodeSeries.Id;
|
||||
dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.ContainsField(ItemFields.SeriesStudio))
|
||||
{
|
||||
episodeSeries ??= episode.Series;
|
||||
@@ -1607,11 +1340,11 @@ namespace Emby.Server.Implementations.Dto
|
||||
}
|
||||
}
|
||||
|
||||
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem)
|
||||
{
|
||||
if (currentItem is MusicAlbum musicAlbum)
|
||||
{
|
||||
var artist = GetBatchedAlbumArtist(musicAlbum, artistsBatch) ?? musicAlbum.GetMusicArtist(new DtoOptions(false));
|
||||
var artist = musicAlbum.GetMusicArtist(new DtoOptions(false));
|
||||
if (artist is not null)
|
||||
{
|
||||
return artist;
|
||||
@@ -1628,36 +1361,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
return parent;
|
||||
}
|
||||
|
||||
private static MusicArtist? GetBatchedAlbumArtist(MusicAlbum album, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
|
||||
{
|
||||
if (artistsBatch is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = album.AlbumArtists.Count > 0 ? album.AlbumArtists[0] : null;
|
||||
return !string.IsNullOrEmpty(name) && artistsBatch.TryGetValue(name, out var artists) && artists.Length > 0
|
||||
? artists[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (item is UserView { ViewType: CollectionType.playlists } playlistsView
|
||||
&& options.GetImageLimit(ImageType.Primary) > 0
|
||||
&& !playlistsView.DisplayParentId.IsEmpty())
|
||||
{
|
||||
var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId);
|
||||
var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0);
|
||||
|
||||
if (displayParentPrimaryImage is not null)
|
||||
{
|
||||
dto.ImageTags?.Remove(ImageType.Primary);
|
||||
dto.ParentPrimaryImageItemId = displayParent!.Id;
|
||||
dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.SupportsInheritedParentImages)
|
||||
{
|
||||
return;
|
||||
@@ -1686,7 +1391,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|| (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0)
|
||||
|| parent is Series)
|
||||
{
|
||||
parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent;
|
||||
parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent;
|
||||
if (parent is null)
|
||||
{
|
||||
break;
|
||||
@@ -1745,7 +1450,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
break;
|
||||
}
|
||||
|
||||
parent = GetImageDisplayParent(parent, item, artistsBatch);
|
||||
parent = GetImageDisplayParent(parent, item);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="prometheus-net.DotNetRuntime" />
|
||||
@@ -38,7 +39,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
@@ -65,7 +66,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Ignore" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -27,11 +27,6 @@ namespace Emby.Server.Implementations.EntryPoints;
|
||||
/// </summary>
|
||||
public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
{
|
||||
// A batch holds a live reference to every item it names, so it has to stay small enough that a
|
||||
// library scan - which changes items faster than any batch window closes - cannot grow it without
|
||||
// bound. Reached only by a scan; interactive use closes a batch on the window long before this.
|
||||
internal const int MaxBatchSize = 2000;
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
private readonly IProviderManager _providerManager;
|
||||
@@ -40,11 +35,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
private readonly ILogger<LibraryChangedNotifier> _logger;
|
||||
|
||||
private readonly Lock _libraryChangedSyncLock = new();
|
||||
private readonly Dictionary<Guid, Folder> _foldersAddedTo = [];
|
||||
private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = [];
|
||||
private readonly Dictionary<Guid, BaseItem> _itemsAdded = [];
|
||||
private readonly Dictionary<Guid, BaseItem> _itemsRemoved = [];
|
||||
private readonly Dictionary<Guid, BaseItem> _itemsUpdated = [];
|
||||
private readonly List<Folder> _foldersAddedTo = new();
|
||||
private readonly List<Folder> _foldersRemovedFrom = new();
|
||||
private readonly List<BaseItem> _itemsAdded = new();
|
||||
private readonly List<BaseItem> _itemsRemoved = new();
|
||||
private readonly List<BaseItem> _itemsUpdated = new();
|
||||
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
|
||||
|
||||
private Timer? _libraryUpdateTimer;
|
||||
@@ -178,7 +173,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
|
||||
=> OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom);
|
||||
|
||||
private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList)
|
||||
private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList)
|
||||
{
|
||||
if (!FilterItem(item))
|
||||
{
|
||||
@@ -187,28 +182,23 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
|
||||
lock (_libraryChangedSyncLock)
|
||||
{
|
||||
// The window runs from the first change of a batch and is never extended. Extending it on
|
||||
// every change would keep a library scan's batch open for the whole scan, and the batch
|
||||
// holds the items it names alive, so it would grow to the size of the library.
|
||||
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
|
||||
|
||||
if (_libraryUpdateTimer is null)
|
||||
{
|
||||
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
|
||||
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
else
|
||||
{
|
||||
_libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
if (foldersList is not null && parent is Folder folder)
|
||||
{
|
||||
foldersList[folder.Id] = folder;
|
||||
foldersList.Add(folder);
|
||||
}
|
||||
|
||||
itemsList[item.Id] = item;
|
||||
|
||||
// A window long enough to cover a burst still has to give way once the batch is large
|
||||
// enough to be worth sending on its own.
|
||||
if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize)
|
||||
{
|
||||
_libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
itemsList.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,16 +211,22 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
List<BaseItem> itemsRemoved;
|
||||
lock (_libraryChangedSyncLock)
|
||||
{
|
||||
foldersAddedTo = _foldersAddedTo.Values.ToList();
|
||||
foldersRemovedFrom = _foldersRemovedFrom.Values.ToList();
|
||||
|
||||
itemsUpdated = _itemsUpdated
|
||||
.Where(e => !_itemsAdded.ContainsKey(e.Key))
|
||||
.Select(e => e.Value)
|
||||
// Remove dupes in case some were saved multiple times
|
||||
foldersAddedTo = _foldersAddedTo
|
||||
.DistinctBy(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
itemsAdded = _itemsAdded.Values.ToList();
|
||||
itemsRemoved = _itemsRemoved.Values.ToList();
|
||||
foldersRemovedFrom = _foldersRemovedFrom
|
||||
.DistinctBy(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
itemsUpdated = _itemsUpdated
|
||||
.Where(i => !_itemsAdded.Contains(i))
|
||||
.DistinctBy(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
itemsAdded = _itemsAdded.ToList();
|
||||
itemsRemoved = _itemsRemoved.ToList();
|
||||
|
||||
if (_libraryUpdateTimer is not null)
|
||||
{
|
||||
@@ -245,15 +241,6 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
_foldersRemovedFrom.Clear();
|
||||
}
|
||||
|
||||
if (itemsAdded.Count == 0
|
||||
&& itemsUpdated.Count == 0
|
||||
&& itemsRemoved.Count == 0
|
||||
&& foldersAddedTo.Count == 0
|
||||
&& foldersRemovedFrom.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,17 +18,15 @@ namespace Emby.Server.Implementations.EntryPoints
|
||||
public sealed class UserDataChangeNotifier : IHostedService, IDisposable
|
||||
{
|
||||
private const int UpdateDuration = 500;
|
||||
internal const int MaxBatchSize = 2000;
|
||||
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = [];
|
||||
private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new();
|
||||
private readonly Lock _syncLock = new();
|
||||
|
||||
private Timer? _updateTimer;
|
||||
private int _changedItemCount;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
|
||||
@@ -71,64 +69,50 @@ namespace Emby.Server.Implementations.EntryPoints
|
||||
|
||||
lock (_syncLock)
|
||||
{
|
||||
// The window runs from the first change of a batch and is never extended, so a stream
|
||||
// of changes that never pauses - a library scan - still closes its batches instead of
|
||||
// holding every item it touched alive until the stream stops.
|
||||
_updateTimer ??= new Timer(
|
||||
UpdateTimerCallback,
|
||||
null,
|
||||
UpdateDuration,
|
||||
Timeout.Infinite);
|
||||
|
||||
if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys))
|
||||
if (_updateTimer is null)
|
||||
{
|
||||
keys = [];
|
||||
_updateTimer = new Timer(
|
||||
UpdateTimerCallback,
|
||||
null,
|
||||
UpdateDuration,
|
||||
Timeout.Infinite);
|
||||
}
|
||||
else
|
||||
{
|
||||
_updateTimer.Change(UpdateDuration, Timeout.Infinite);
|
||||
}
|
||||
|
||||
if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys))
|
||||
{
|
||||
keys = new List<BaseItem>();
|
||||
_changedItems[e.UserId] = keys;
|
||||
}
|
||||
|
||||
keys.Add(e.Item);
|
||||
|
||||
var baseItem = e.Item;
|
||||
|
||||
// Go up one level for indicators
|
||||
if (baseItem is not null)
|
||||
{
|
||||
Track(keys, baseItem);
|
||||
|
||||
var parent = baseItem.GetOwner() ?? baseItem.GetParent();
|
||||
|
||||
if (parent is not null)
|
||||
{
|
||||
Track(keys, parent);
|
||||
keys.Add(parent);
|
||||
}
|
||||
}
|
||||
|
||||
// A window long enough to cover a burst still has to give way once the batch is
|
||||
// large enough to be worth sending on its own.
|
||||
if (_changedItemCount >= MaxBatchSize)
|
||||
{
|
||||
_updateTimer.Change(0, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item)
|
||||
{
|
||||
var before = keys.Count;
|
||||
keys[item.Id] = item;
|
||||
|
||||
if (keys.Count != before)
|
||||
{
|
||||
_changedItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private async void UpdateTimerCallback(object? state)
|
||||
{
|
||||
List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes;
|
||||
List<KeyValuePair<Guid, List<BaseItem>>> changes;
|
||||
lock (_syncLock)
|
||||
{
|
||||
// Remove dupes in case some were saved multiple times
|
||||
changes = _changedItems.ToList();
|
||||
_changedItems.Clear();
|
||||
_changedItemCount = 0;
|
||||
|
||||
if (_updateTimer is not null)
|
||||
{
|
||||
@@ -137,22 +121,17 @@ namespace Emby.Server.Implementations.EntryPoints
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (userId, changedItems) in changes)
|
||||
{
|
||||
await _sessionManager.SendMessageToUserSessions(
|
||||
[userId],
|
||||
SessionMessageType.UserDataChanged,
|
||||
() => GetUserDataChangeInfo(userId, changedItems.Values),
|
||||
() => GetUserDataChangeInfo(userId, changedItems),
|
||||
default).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems)
|
||||
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems)
|
||||
{
|
||||
var user = _userManager.GetUserById(userId)
|
||||
?? throw new ArgumentException("Invalid user ID", nameof(userId));
|
||||
@@ -161,6 +140,7 @@ namespace Emby.Server.Implementations.EntryPoints
|
||||
{
|
||||
UserId = userId,
|
||||
UserDataList = changedItems
|
||||
.DistinctBy(x => x.Id)
|
||||
.Select(i =>
|
||||
{
|
||||
var dto = _userDataManager.GetUserDataDto(i, user);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.IO.Pipelines;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
@@ -70,11 +69,6 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
/// <inheritdoc />
|
||||
public IPAddress? RemoteEndPoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or initializes the UI culture captured from the upgrade request.
|
||||
/// </summary>
|
||||
public CultureInfo? RequestUICulture { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; }
|
||||
|
||||
@@ -87,17 +81,6 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
/// <inheritdoc />
|
||||
public WebSocketState State => _socket.State;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ApplyRequestCulture()
|
||||
{
|
||||
if (RequestUICulture is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CultureInfo.CurrentUICulture = RequestUICulture;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -127,12 +110,8 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
{
|
||||
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException)
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
// ObjectDisposedException/OperationCanceledException: the socket was torn
|
||||
// down underneath us (e.g. by the keep-alive watchdog after the connection
|
||||
// was declared lost). Fall through so Closed is still raised and the
|
||||
// session can release this connection.
|
||||
_logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading.Tasks;
|
||||
@@ -48,18 +47,14 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
|
||||
|
||||
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
|
||||
|
||||
var connection = new WebSocketConnection(
|
||||
_loggerFactory.CreateLogger<WebSocketConnection>(),
|
||||
webSocket,
|
||||
authorizationInfo,
|
||||
context.GetNormalizedRemoteIP())
|
||||
{
|
||||
RequestUICulture = CultureInfo.CurrentUICulture
|
||||
};
|
||||
connection.OnReceive = result =>
|
||||
{
|
||||
connection.ApplyRequestCulture();
|
||||
return ProcessWebSocketMessageReceived(result);
|
||||
OnReceive = ProcessWebSocketMessageReceived
|
||||
};
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ using Emby.Server.Implementations.Library;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -22,8 +21,6 @@ namespace Emby.Server.Implementations.IO
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||
|
||||
/// <summary>
|
||||
/// The file system watchers.
|
||||
@@ -49,27 +46,20 @@ namespace Emby.Server.Implementations.IO
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="configurationManager">The configuration manager.</param>
|
||||
/// <param name="fileSystem">The filesystem.</param>
|
||||
/// <param name="directoryService">The directory service.</param>
|
||||
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
|
||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||
public LibraryMonitor(
|
||||
ILogger<LibraryMonitor> logger,
|
||||
ILibraryManager libraryManager,
|
||||
IServerConfigurationManager configurationManager,
|
||||
IFileSystem fileSystem,
|
||||
IDirectoryService directoryService,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
|
||||
IHostApplicationLifetime appLifetime)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_configurationManager = configurationManager;
|
||||
_fileSystem = fileSystem;
|
||||
_directoryService = directoryService;
|
||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||
|
||||
appLifetime.ApplicationStarted.Register(Start);
|
||||
appLifetime.ApplicationStopping.Register(Stop);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -363,13 +353,11 @@ namespace Emby.Server.Implementations.IO
|
||||
}
|
||||
|
||||
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||
if (_dotIgnoreIgnoreRule.ShouldIgnore(fileInfo, null))
|
||||
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_directoryService.Invalidate(path);
|
||||
|
||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||
foreach (var i in _tempIgnoredPaths.Keys)
|
||||
{
|
||||
|
||||
@@ -586,12 +586,6 @@ namespace Emby.Server.Implementations.IO
|
||||
/// <inheritdoc />
|
||||
public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, string searchPattern, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive = false)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
_logger.LogWarning("Directory does not exist: {Path}", path);
|
||||
return [];
|
||||
}
|
||||
|
||||
var enumerationOptions = GetEnumerationOptions(recursive);
|
||||
|
||||
// On linux and macOS the search pattern is case-sensitive
|
||||
@@ -691,7 +685,7 @@ namespace Emby.Server.Implementations.IO
|
||||
}
|
||||
catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException)
|
||||
{
|
||||
_logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message);
|
||||
_logger.LogError(ex, "Failed to enumerate path {Path}", path);
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
@@ -15,6 +14,7 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
|
||||
namespace Emby.Server.Implementations.Images
|
||||
{
|
||||
@@ -28,23 +28,51 @@ namespace Emby.Server.Implementations.Images
|
||||
{
|
||||
var view = (CollectionFolder)item;
|
||||
var viewType = view.CollectionType;
|
||||
var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
|
||||
var recursive = viewType != CollectionType.playlists;
|
||||
|
||||
if (viewType == CollectionType.music)
|
||||
BaseItemKind[] includeItemTypes;
|
||||
|
||||
switch (viewType)
|
||||
{
|
||||
// Music albums usually don't have dedicated backdrops, so use artist instead
|
||||
includeItemTypes = [BaseItemKind.MusicArtist];
|
||||
case CollectionType.movies:
|
||||
includeItemTypes = new[] { BaseItemKind.Movie };
|
||||
break;
|
||||
case CollectionType.tvshows:
|
||||
includeItemTypes = new[] { BaseItemKind.Series };
|
||||
break;
|
||||
case CollectionType.music:
|
||||
includeItemTypes = new[] { BaseItemKind.MusicAlbum };
|
||||
break;
|
||||
case CollectionType.musicvideos:
|
||||
includeItemTypes = new[] { BaseItemKind.MusicVideo };
|
||||
break;
|
||||
case CollectionType.books:
|
||||
includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
|
||||
break;
|
||||
case CollectionType.boxsets:
|
||||
includeItemTypes = new[] { BaseItemKind.BoxSet };
|
||||
break;
|
||||
case CollectionType.homevideos:
|
||||
case CollectionType.photos:
|
||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo };
|
||||
break;
|
||||
default:
|
||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series };
|
||||
break;
|
||||
}
|
||||
|
||||
var recursive = viewType != CollectionType.playlists;
|
||||
|
||||
return view.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
CollapseBoxSetItems = false,
|
||||
Recursive = recursive,
|
||||
DtoOptions = new DtoOptions(false),
|
||||
ImageTypes = [ImageType.Primary],
|
||||
ImageTypes = new[] { ImageType.Primary },
|
||||
Limit = 8,
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)],
|
||||
OrderBy = new[]
|
||||
{
|
||||
(ItemSortBy.Random, SortOrder.Ascending)
|
||||
},
|
||||
IncludeItemTypes = includeItemTypes
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using BitFaster.Caching.Lru;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
@@ -17,36 +15,22 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
{
|
||||
private static readonly bool IsWindows = OperatingSystem.IsWindows();
|
||||
|
||||
private readonly FastConcurrentLru<string, IgnoreFileCacheEntry> _directoryCache;
|
||||
private readonly FastConcurrentLru<string, ParsedIgnoreCacheEntry> _rulesCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DotIgnoreIgnoreRule"/> class.
|
||||
/// </summary>
|
||||
public DotIgnoreIgnoreRule()
|
||||
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
|
||||
{
|
||||
var cacheSize = Math.Max(100, Environment.ProcessorCount * 100);
|
||||
_directoryCache = new FastConcurrentLru<string, IgnoreFileCacheEntry>(
|
||||
Environment.ProcessorCount,
|
||||
cacheSize,
|
||||
StringComparer.Ordinal);
|
||||
_rulesCache = new FastConcurrentLru<string, ParsedIgnoreCacheEntry>(
|
||||
Environment.ProcessorCount,
|
||||
Math.Max(32, cacheSize / 4),
|
||||
StringComparer.Ordinal);
|
||||
for (var current = directory; current is not null; current = current.Parent)
|
||||
{
|
||||
var ignorePath = Path.Join(current.FullName, ".ignore");
|
||||
if (File.Exists(ignorePath))
|
||||
{
|
||||
return new FileInfo(ignorePath);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnoredInternal(fileInfo, parent);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the directory lookup cache. The parsed rules cache is not cleared
|
||||
/// as it validates file modification time on each access.
|
||||
/// </summary>
|
||||
public void ClearDirectoryCache()
|
||||
{
|
||||
_directoryCache.Clear();
|
||||
}
|
||||
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether or not the file is ignored.
|
||||
@@ -54,38 +38,40 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
/// <param name="fileInfo">The file information.</param>
|
||||
/// <param name="parent">The parent BaseItem.</param>
|
||||
/// <returns>True if the file should be ignored.</returns>
|
||||
public bool IsIgnoredInternal(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||
{
|
||||
var searchDirectory = fileInfo.IsDirectory
|
||||
? fileInfo.FullName
|
||||
: Path.GetDirectoryName(fileInfo.FullName);
|
||||
? new DirectoryInfo(fileInfo.FullName)
|
||||
: new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrEmpty(searchDirectory))
|
||||
if (string.IsNullOrEmpty(searchDirectory.FullName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ignoreFile = FindIgnoreFileCached(searchDirectory);
|
||||
var ignoreFile = FindIgnoreFile(searchDirectory);
|
||||
if (ignoreFile is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsedEntry = GetParsedRules(ignoreFile);
|
||||
if (parsedEntry is null)
|
||||
{
|
||||
// File was deleted after we cached the path - clear the directory cache entry and return false
|
||||
_directoryCache.TryRemove(searchDirectory, out _);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Empty file means ignore everything
|
||||
if (parsedEntry.IsEmpty)
|
||||
// Fast path in case the ignore files isn't a symlink and is empty
|
||||
if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
|
||||
{
|
||||
// Ignore directory if we just have the file
|
||||
return true;
|
||||
}
|
||||
|
||||
return parsedEntry.Rules.IsIgnored(GetPathToCheck(fileInfo.FullName, fileInfo.IsDirectory));
|
||||
var content = GetFileContent(ignoreFile);
|
||||
return string.IsNullOrWhiteSpace(content)
|
||||
|| CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
|
||||
}
|
||||
|
||||
private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
|
||||
{
|
||||
// If file has content, base ignoring off the content .gitignore-style rules
|
||||
var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return CheckIgnoreRules(path, rules, isDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -131,8 +117,8 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
|
||||
|
||||
// Add trailing slash for directories to match "folder/"
|
||||
@@ -144,196 +130,11 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
return ignore.IsIgnored(pathToCheck);
|
||||
}
|
||||
|
||||
private FileInfo? FindIgnoreFileCached(string directory)
|
||||
private static string GetFileContent(FileInfo ignoreFile)
|
||||
{
|
||||
// Check if we have a cached result for this directory
|
||||
if (_directoryCache.TryGet(directory, out var cached))
|
||||
{
|
||||
return cached.IgnoreFileDirectory is null
|
||||
? null
|
||||
: new FileInfo(Path.Join(cached.IgnoreFileDirectory, ".ignore"));
|
||||
}
|
||||
|
||||
DirectoryInfo startDir;
|
||||
try
|
||||
{
|
||||
startDir = new DirectoryInfo(directory);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Walk up the directory tree to find .ignore file using DirectoryInfo.Parent
|
||||
var checkedDirs = new List<string> { directory };
|
||||
|
||||
for (var current = startDir; current is not null; current = current.Parent)
|
||||
{
|
||||
var currentPath = current.FullName;
|
||||
|
||||
// Check if this intermediate directory is cached
|
||||
if (current != startDir && _directoryCache.TryGet(currentPath, out var parentCached))
|
||||
{
|
||||
// Cache the result for all directories we checked
|
||||
var entry = new IgnoreFileCacheEntry(parentCached.IgnoreFileDirectory);
|
||||
foreach (var dir in checkedDirs)
|
||||
{
|
||||
_directoryCache.AddOrUpdate(dir, entry);
|
||||
}
|
||||
|
||||
return parentCached.IgnoreFileDirectory is null
|
||||
? null
|
||||
: new FileInfo(Path.Join(parentCached.IgnoreFileDirectory, ".ignore"));
|
||||
}
|
||||
|
||||
var ignoreFile = new FileInfo(Path.Join(currentPath, ".ignore"));
|
||||
if (ignoreFile.Exists)
|
||||
{
|
||||
// Cache for all directories we checked
|
||||
var entry = new IgnoreFileCacheEntry(currentPath);
|
||||
foreach (var dir in checkedDirs)
|
||||
{
|
||||
_directoryCache.AddOrUpdate(dir, entry);
|
||||
}
|
||||
|
||||
return ignoreFile;
|
||||
}
|
||||
|
||||
if (current != startDir)
|
||||
{
|
||||
checkedDirs.Add(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// No .ignore file found - cache null result for all directories
|
||||
var nullEntry = new IgnoreFileCacheEntry((string?)null);
|
||||
foreach (var dir in checkedDirs)
|
||||
{
|
||||
_directoryCache.AddOrUpdate(dir, nullEntry);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private ParsedIgnoreCacheEntry? GetParsedRules(FileInfo ignoreFile)
|
||||
{
|
||||
if (!ignoreFile.Exists)
|
||||
{
|
||||
_rulesCache.TryRemove(ignoreFile.FullName, out _);
|
||||
return null;
|
||||
}
|
||||
|
||||
var lastModified = ignoreFile.LastWriteTimeUtc;
|
||||
var fileLength = ignoreFile.Length;
|
||||
var key = ignoreFile.FullName;
|
||||
|
||||
// Check cache
|
||||
if (_rulesCache.TryGet(key, out var cached))
|
||||
{
|
||||
if (cached.FileLastModified == lastModified && cached.FileLength == fileLength)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Stale - need to reparse
|
||||
_rulesCache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
// Parse the file
|
||||
var parsedEntry = ParseIgnoreFile(ignoreFile, lastModified, fileLength);
|
||||
_rulesCache.AddOrUpdate(key, parsedEntry);
|
||||
return parsedEntry;
|
||||
}
|
||||
|
||||
private static ParsedIgnoreCacheEntry ParseIgnoreFile(FileInfo ignoreFile, DateTime lastModified, long fileLength)
|
||||
{
|
||||
if (ignoreFile.LinkTarget is null && fileLength == 0)
|
||||
{
|
||||
return new ParsedIgnoreCacheEntry
|
||||
{
|
||||
Rules = new Ignore.Ignore(),
|
||||
FileLastModified = lastModified,
|
||||
FileLength = fileLength,
|
||||
IsEmpty = true
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve symlinks
|
||||
var resolvedFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
|
||||
if (!resolvedFile.Exists)
|
||||
{
|
||||
return new ParsedIgnoreCacheEntry
|
||||
{
|
||||
Rules = new Ignore.Ignore(),
|
||||
FileLastModified = lastModified,
|
||||
FileLength = fileLength,
|
||||
IsEmpty = true
|
||||
};
|
||||
}
|
||||
|
||||
var content = File.ReadAllText(resolvedFile.FullName);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return new ParsedIgnoreCacheEntry
|
||||
{
|
||||
Rules = new Ignore.Ignore(),
|
||||
FileLastModified = lastModified,
|
||||
FileLength = fileLength,
|
||||
IsEmpty = true
|
||||
};
|
||||
}
|
||||
|
||||
var rules = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var ignore = new Ignore.Ignore();
|
||||
var validRulesAdded = 0;
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
try
|
||||
{
|
||||
ignore.Add(rule);
|
||||
validRulesAdded++;
|
||||
}
|
||||
catch (RegexParseException)
|
||||
{
|
||||
// Ignore invalid patterns
|
||||
}
|
||||
}
|
||||
|
||||
// No valid rules means treat as empty (ignore all)
|
||||
return new ParsedIgnoreCacheEntry
|
||||
{
|
||||
Rules = ignore,
|
||||
FileLastModified = lastModified,
|
||||
FileLength = fileLength,
|
||||
IsEmpty = validRulesAdded == 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPathToCheck(string path, bool isDirectory)
|
||||
{
|
||||
// Normalize Windows paths
|
||||
var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
|
||||
|
||||
// Add trailing slash for directories to match "folder/"
|
||||
if (isDirectory)
|
||||
{
|
||||
pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
|
||||
}
|
||||
|
||||
return pathToCheck;
|
||||
}
|
||||
|
||||
private readonly record struct IgnoreFileCacheEntry(string? IgnoreFileDirectory);
|
||||
|
||||
private sealed class ParsedIgnoreCacheEntry
|
||||
{
|
||||
public required Ignore.Ignore Rules { get; init; }
|
||||
|
||||
public required DateTime FileLastModified { get; init; }
|
||||
|
||||
public required long FileLength { get; init; }
|
||||
|
||||
public required bool IsEmpty { get; init; }
|
||||
ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
|
||||
return ignoreFile.Exists
|
||||
? File.ReadAllText(ignoreFile.FullName)
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Chapters;
|
||||
@@ -51,33 +52,26 @@ public class ExternalDataManager : IExternalDataManager
|
||||
/// <inheritdoc/>
|
||||
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
DeleteExternalItemFiles(item);
|
||||
|
||||
var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
|
||||
var itemId = item.Id;
|
||||
if (validPaths.Count > 0)
|
||||
{
|
||||
foreach (var path in validPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void DeleteExternalItemFiles(BaseItem item)
|
||||
{
|
||||
foreach (var path in _pathManager.GetExtractedDataPaths(item))
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,20 +31,6 @@ namespace Emby.Server.Implementations.Library
|
||||
"**/*.sample.?????",
|
||||
"**/sample/*",
|
||||
|
||||
// Avoid adding Hungarian sample files
|
||||
// https://github.com/jellyfin/jellyfin/issues/16237
|
||||
"**/minta.?",
|
||||
"**/minta.??",
|
||||
"**/minta.???", // Matches minta.mkv
|
||||
"**/minta.????", // Matches minta.webm
|
||||
"**/minta.?????",
|
||||
"**/*.minta.?",
|
||||
"**/*.minta.??",
|
||||
"**/*.minta.???",
|
||||
"**/*.minta.????",
|
||||
"**/*.minta.?????",
|
||||
"**/minta/*",
|
||||
|
||||
// Directories
|
||||
"**/metadata/**",
|
||||
"**/metadata",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,6 @@ using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
@@ -128,11 +126,6 @@ namespace Emby.Server.Implementations.Library
|
||||
return true;
|
||||
}
|
||||
|
||||
if (stream.IsVobSubSubtitleStream)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -177,7 +170,6 @@ namespace Emby.Server.Implementations.Library
|
||||
public async Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
|
||||
{
|
||||
var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
|
||||
ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
|
||||
|
||||
// If file is strm or main media stream is missing, force a metadata refresh with remote probing
|
||||
if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder
|
||||
@@ -194,7 +186,6 @@ namespace Emby.Server.Implementations.Library
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
|
||||
ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
|
||||
}
|
||||
|
||||
var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false);
|
||||
@@ -229,11 +220,7 @@ namespace Emby.Server.Implementations.Library
|
||||
list.Add(source);
|
||||
}
|
||||
|
||||
var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId)
|
||||
? topSourceId
|
||||
: item.Id;
|
||||
|
||||
return SortMediaSources(list, preferredId).ToArray();
|
||||
return SortMediaSources(list).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />>
|
||||
@@ -331,28 +318,6 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves symlinked file paths on the supplied sources to the real on-disk target.
|
||||
/// Skipped when <paramref name="enablePathSubstitution"/> is set because the path may
|
||||
/// already have been rewritten to a UNC/URL meant for the client to consume directly.
|
||||
/// </summary>
|
||||
private static void ResolveSymlinkPaths(IReadOnlyList<MediaSourceInfo> sources, bool enablePathSubstitution)
|
||||
{
|
||||
if (enablePathSubstitution)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (source.Protocol == MediaProtocol.File
|
||||
&& FileSystemHelper.ResolveLinkTarget(source.Path, returnFinalTarget: true) is { Exists: true } target)
|
||||
{
|
||||
source.Path = target.FullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource)
|
||||
{
|
||||
var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter;
|
||||
@@ -390,12 +355,6 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
sources = sources
|
||||
.Where(source => !Guid.TryParse(source.Id, out var sourceId)
|
||||
|| sourceId.Equals(item.Id)
|
||||
|| _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null)
|
||||
.ToArray();
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
|
||||
@@ -410,59 +369,6 @@ namespace Emby.Server.Implementations.Library
|
||||
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing);
|
||||
}
|
||||
}
|
||||
|
||||
sources = SetAlternateVersionResumeStates(item, sources, user);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the queried item is a primary, moves the most recently played version to the front so
|
||||
/// that resuming without an explicit source selection plays the version that was last watched.
|
||||
/// A directly queried alternate version keeps its own source first. Per-user playback position
|
||||
/// is not surfaced on the source itself; it is carried by each version's own UserData.
|
||||
/// </summary>
|
||||
/// <param name="item">The queried item.</param>
|
||||
/// <param name="sources">The item's media sources.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>The media sources, reordered when a version drives resume.</returns>
|
||||
private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user)
|
||||
{
|
||||
// For a video, multiple sources means alternate versions.
|
||||
if (item is not Video video || sources.Count < 2)
|
||||
{
|
||||
return sources;
|
||||
}
|
||||
|
||||
var versions = video.GetAllVersions();
|
||||
if (versions.Count < 2)
|
||||
{
|
||||
return sources;
|
||||
}
|
||||
|
||||
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
|
||||
var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var version in versions)
|
||||
{
|
||||
if (userDataByVersion.TryGetValue(version.Id, out var data))
|
||||
{
|
||||
dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder only for a resumable (in-progress) version;
|
||||
// a completed version has no position to resume, so it must not be pulled to the front here.
|
||||
var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||
sources,
|
||||
source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null,
|
||||
data => data.PlaybackPositionTicks > 0);
|
||||
|
||||
if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource))
|
||||
{
|
||||
var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource };
|
||||
reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource)));
|
||||
return reordered;
|
||||
}
|
||||
|
||||
return sources;
|
||||
@@ -517,7 +423,7 @@ namespace Emby.Server.Implementations.Library
|
||||
MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage);
|
||||
}
|
||||
|
||||
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection, string originalLanguage)
|
||||
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
|
||||
{
|
||||
if (userData is not null && userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection)
|
||||
{
|
||||
@@ -531,38 +437,7 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (user.PlayDefaultAudioTrack)
|
||||
{
|
||||
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(
|
||||
source.MediaStreams,
|
||||
NormalizeLanguage(originalLanguage),
|
||||
user.PlayDefaultAudioTrack);
|
||||
return;
|
||||
}
|
||||
|
||||
var originalIndex = source.MediaStreams.FindIndex(i => i.Type == MediaStreamType.Audio && i.IsOriginal);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(originalLanguage) && originalIndex != -1)
|
||||
{
|
||||
var mediaLanguageOriginal = source.MediaStreams[originalIndex].Language;
|
||||
if (NormalizeLanguage(mediaLanguageOriginal).Contains(NormalizeLanguage(originalLanguage).FirstOrDefault()))
|
||||
{
|
||||
source.DefaultAudioStreamIndex = originalIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (originalIndex != -1)
|
||||
{
|
||||
source.DefaultAudioStreamIndex = originalIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var preferredAudio = string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(originalLanguage)
|
||||
? NormalizeLanguage(originalLanguage)
|
||||
: NormalizeLanguage(user.AudioLanguagePreference);
|
||||
var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference);
|
||||
|
||||
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack);
|
||||
if (user.PlayDefaultAudioTrack)
|
||||
@@ -587,9 +462,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections;
|
||||
|
||||
var originalLanguage = item?.GetInheritedOriginalLanguage();
|
||||
|
||||
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage);
|
||||
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection);
|
||||
SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);
|
||||
}
|
||||
else if (mediaType == MediaType.Audio)
|
||||
@@ -603,32 +476,24 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default)
|
||||
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
|
||||
{
|
||||
// The source belonging to the queried item sorts first so it stays the default that gets played.
|
||||
var preferredId = preferredItemId.IsEmpty()
|
||||
? null
|
||||
: preferredItemId.ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
return sources
|
||||
.OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenBy(i =>
|
||||
return sources.OrderBy(i =>
|
||||
{
|
||||
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
|
||||
{
|
||||
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
})
|
||||
.ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
||||
.ThenByDescending(i =>
|
||||
{
|
||||
var stream = i.VideoStream;
|
||||
return 1;
|
||||
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
||||
.ThenByDescending(i =>
|
||||
{
|
||||
var stream = i.VideoStream;
|
||||
|
||||
return stream?.Width ?? 0;
|
||||
})
|
||||
.Where(i => i.Type != MediaSourceType.Placeholder);
|
||||
return stream?.Width ?? 0;
|
||||
})
|
||||
.Where(i => i.Type != MediaSourceType.Placeholder);
|
||||
}
|
||||
|
||||
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -29,79 +29,28 @@ namespace Emby.Server.Implementations.Library
|
||||
throw new ArgumentException("String can't be empty.", nameof(attribute));
|
||||
}
|
||||
|
||||
// Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc.
|
||||
// The code below only supports aliases for attributes in the form of "<alias>id".
|
||||
ReadOnlySpan<char> shortAttr = attribute switch
|
||||
var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Must be at least 3 characters after the attribute =, ], any character,
|
||||
// then we offset it by 1, because we want the index and not length.
|
||||
var maxIndex = str.Length - attribute.Length - 2;
|
||||
while (attributeIndex > -1 && attributeIndex < maxIndex)
|
||||
{
|
||||
_ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
|
||||
_ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
|
||||
_ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
|
||||
_ => ReadOnlySpan<char>.Empty
|
||||
};
|
||||
|
||||
for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
|
||||
{
|
||||
// We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'.
|
||||
var subStr = str[strIndex..];
|
||||
int attributeEnd = 0;
|
||||
|
||||
if (shortAttr.Length > 0)
|
||||
var attributeEnd = attributeIndex + attribute.Length;
|
||||
if (attributeIndex > 0
|
||||
&& str[attributeIndex - 1] == '['
|
||||
&& (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
|
||||
{
|
||||
// If we are using an alias it should be shorter (and a prefix), so let's search for that.
|
||||
attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
|
||||
attributeEnd = attributeIndex + shortAttr.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
||||
attributeEnd = attributeIndex + attribute.Length;
|
||||
}
|
||||
|
||||
// The next iteration should start at the end of the attribute we just found.
|
||||
// If attributeIndex < 0, the loop will end and strIndex won't be used again.
|
||||
strIndex += attributeEnd;
|
||||
|
||||
if (attributeIndex > 0)
|
||||
{
|
||||
var attributeOpener = subStr[attributeIndex - 1];
|
||||
var attributeCloser = attributeOpener switch
|
||||
var closingIndex = str[attributeEnd..].IndexOf(']');
|
||||
// Must be at least 1 character before the closing bracket.
|
||||
if (closingIndex > 1)
|
||||
{
|
||||
'[' => ']',
|
||||
'(' => ')',
|
||||
'{' => '}',
|
||||
_ => '\0'
|
||||
};
|
||||
|
||||
if (attributeCloser != '\0')
|
||||
{
|
||||
if (shortAttr.Length > 0
|
||||
&& attributeEnd + 1 < subStr.Length
|
||||
&& (subStr[attributeEnd] is 'i' or 'I')
|
||||
&& (subStr[attributeEnd + 1] is 'd' or 'D'))
|
||||
{
|
||||
// We were searching for a shortened attribute, but it's followed by "id" - let's skip it.
|
||||
attributeEnd += 2;
|
||||
}
|
||||
|
||||
// attributeEnd points at '='.
|
||||
// We need at least 1 more character and the closing bracket after that.
|
||||
if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-'))
|
||||
{
|
||||
var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser);
|
||||
|
||||
// Must be at least 1 character before the closing bracket.
|
||||
if (closingIndex > 1)
|
||||
{
|
||||
var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim();
|
||||
|
||||
if (trimmed.Length > 0)
|
||||
{
|
||||
return trimmed.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
|
||||
}
|
||||
}
|
||||
|
||||
str = str[attributeEnd..];
|
||||
attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// for imdbid we also accept pattern matching
|
||||
|
||||
@@ -43,12 +43,6 @@ public class PathManager : IPathManager
|
||||
/// <inheritdoc />
|
||||
public string? GetAttachmentPath(string mediaSourceId, string fileName)
|
||||
{
|
||||
var folder = GetAttachmentFolderPath(mediaSourceId);
|
||||
if (folder is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var safeName = PathHelper.GetSafeLeafFileName(fileName);
|
||||
if (safeName is null)
|
||||
{
|
||||
@@ -56,40 +50,29 @@ public class PathManager : IPathManager
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.Combine(folder, safeName);
|
||||
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), safeName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetAttachmentFolderPath(string mediaSourceId)
|
||||
public string GetAttachmentFolderPath(string mediaSourceId)
|
||||
{
|
||||
if (!Guid.TryParse(mediaSourceId, out var parsed))
|
||||
{
|
||||
_logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk attachment folder.", mediaSourceId);
|
||||
return null;
|
||||
}
|
||||
var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||
|
||||
var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||
return Path.Join(AttachmentCachePath, id[..2], id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetSubtitleFolderPath(string mediaSourceId)
|
||||
public string GetSubtitleFolderPath(string mediaSourceId)
|
||||
{
|
||||
if (!Guid.TryParse(mediaSourceId, out var parsed))
|
||||
{
|
||||
_logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk subtitle folder.", mediaSourceId);
|
||||
return null;
|
||||
}
|
||||
var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||
|
||||
var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||
return Path.Join(SubtitleCachePath, id[..2], id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
|
||||
public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
|
||||
{
|
||||
var folder = GetSubtitleFolderPath(mediaSourceId);
|
||||
return folder is null ? null : Path.Combine(folder, streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
|
||||
return Path.Combine(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -120,27 +103,12 @@ public class PathManager : IPathManager
|
||||
public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item)
|
||||
{
|
||||
var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
List<string> paths = [];
|
||||
var attachmentFolder = GetAttachmentFolderPath(mediaSourceId);
|
||||
if (attachmentFolder is not null)
|
||||
{
|
||||
paths.Add(attachmentFolder);
|
||||
}
|
||||
|
||||
var subtitleFolder = GetSubtitleFolderPath(mediaSourceId);
|
||||
if (subtitleFolder is not null)
|
||||
{
|
||||
paths.Add(subtitleFolder);
|
||||
}
|
||||
|
||||
paths.Add(GetTrickplayDirectory(item, false));
|
||||
if (!string.IsNullOrEmpty(item.Path))
|
||||
{
|
||||
paths.Add(GetTrickplayDirectory(item, true));
|
||||
}
|
||||
|
||||
paths.Add(GetChapterImageFolderPath(item));
|
||||
|
||||
return paths;
|
||||
return [
|
||||
GetAttachmentFolderPath(mediaSourceId),
|
||||
GetSubtitleFolderPath(mediaSourceId),
|
||||
GetTrickplayDirectory(item, false),
|
||||
GetTrickplayDirectory(item, true),
|
||||
GetChapterImageFolderPath(item)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Emby.Naming.Book;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
{
|
||||
@@ -16,7 +18,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
{
|
||||
private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" };
|
||||
|
||||
protected override Book? Resolve(ItemResolveArgs args)
|
||||
protected override Book Resolve(ItemResolveArgs args)
|
||||
{
|
||||
var collectionType = args.GetCollectionType();
|
||||
|
||||
@@ -33,26 +35,20 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
|
||||
var extension = Path.GetExtension(args.Path.AsSpan());
|
||||
|
||||
if (!_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
if (_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
// It's a book
|
||||
return new Book
|
||||
{
|
||||
Path = args.Path,
|
||||
IsInMixedFolder = true
|
||||
};
|
||||
}
|
||||
|
||||
var result = BookFileNameParser.Parse(Path.GetFileNameWithoutExtension(args.Path));
|
||||
|
||||
return new Book
|
||||
{
|
||||
Path = args.Path,
|
||||
Name = result.Name ?? string.Empty,
|
||||
IndexNumber = result.Index,
|
||||
ParentIndexNumber = result.ParentIndex,
|
||||
ProductionYear = result.Year,
|
||||
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
|
||||
IsInMixedFolder = true,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
private Book? GetBook(ItemResolveArgs args)
|
||||
private Book GetBook(ItemResolveArgs args)
|
||||
{
|
||||
var bookFiles = args.FileSystemChildren.Where(f =>
|
||||
{
|
||||
@@ -63,23 +59,15 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}).ToList();
|
||||
|
||||
// directory is only considered a book when it contains exactly one supported file
|
||||
// other library structures with multiple books to a directory will get picked up as individual files
|
||||
// Don't return a Book if there is more (or less) than one document in the directory
|
||||
if (bookFiles.Count != 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = BookFileNameParser.Parse(Path.GetFileName(args.Path));
|
||||
|
||||
return new Book
|
||||
{
|
||||
Path = bookFiles[0].FullName,
|
||||
Name = result.Name ?? string.Empty,
|
||||
IndexNumber = result.Index,
|
||||
ParentIndexNumber = result.ParentIndex,
|
||||
ProductionYear = result.Year,
|
||||
SeriesName = result.SeriesName ?? string.Empty,
|
||||
Path = bookFiles[0].FullName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
: base(logger, namingOptions, directoryService)
|
||||
{
|
||||
_namingOptions = namingOptions;
|
||||
_trailerResolvers = [new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService, parseName: true)];
|
||||
_videoResolvers = [this];
|
||||
_trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) };
|
||||
_videoResolvers = new IItemResolver[] { this };
|
||||
}
|
||||
|
||||
protected override Video Resolve(ItemResolveArgs args)
|
||||
@@ -54,13 +54,12 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
_ => _videoResolvers
|
||||
};
|
||||
|
||||
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "")
|
||||
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "")
|
||||
{
|
||||
var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot);
|
||||
if (extraResult.ExtraType is null || extraResult.Rule is null)
|
||||
if (extraResult.ExtraType is null)
|
||||
{
|
||||
extraType = null;
|
||||
extraRule = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -89,7 +88,6 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
}
|
||||
|
||||
extraType = extraResult.ExtraType;
|
||||
extraRule = extraResult.Rule;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using Emby.Naming.Common;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -15,25 +14,15 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
public class GenericVideoResolver<T> : BaseVideoResolver<T>
|
||||
where T : Video, new()
|
||||
{
|
||||
private readonly bool _parseName;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="namingOptions">The naming options.</param>
|
||||
/// <param name="directoryService">The directory service.</param>
|
||||
/// <param name="parseName">Whether to parse the file name for metadata such as the year.</param>
|
||||
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService, bool parseName = false)
|
||||
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService)
|
||||
: base(logger, namingOptions, directoryService)
|
||||
{
|
||||
_parseName = parseName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override T Resolve(ItemResolveArgs args)
|
||||
{
|
||||
return ResolveVideo<T>(args, _parseName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,16 +28,15 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
public partial class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver
|
||||
{
|
||||
private readonly IImageProcessor _imageProcessor;
|
||||
private readonly VideoListResolver _videoListResolver;
|
||||
|
||||
private static readonly CollectionType[] _validCollectionTypes =
|
||||
[
|
||||
private static readonly CollectionType[] _validCollectionTypes = new[]
|
||||
{
|
||||
CollectionType.movies,
|
||||
CollectionType.homevideos,
|
||||
CollectionType.musicvideos,
|
||||
CollectionType.tvshows,
|
||||
CollectionType.photos
|
||||
];
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieResolver"/> class.
|
||||
@@ -46,12 +45,10 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="namingOptions">The naming options.</param>
|
||||
/// <param name="directoryService">The directory service.</param>
|
||||
/// <param name="videoListResolver">The video list resolver.</param>
|
||||
public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService, VideoListResolver videoListResolver)
|
||||
public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService)
|
||||
: base(logger, namingOptions, directoryService)
|
||||
{
|
||||
_imageProcessor = imageProcessor;
|
||||
_videoListResolver = videoListResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -231,7 +228,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
|
||||
if (collectionType == CollectionType.tvshows)
|
||||
{
|
||||
return ResolveVideos<Episode>(parent, files, true, collectionType, true);
|
||||
return ResolveVideos<Episode>(parent, files, false, collectionType, true);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -277,7 +274,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
.Where(f => f is not null)
|
||||
.ToList();
|
||||
|
||||
var resolverResult = _videoListResolver.Resolve(videoInfos, supportMultiEditions, parseName, parent.ContainingFolderPath, collectionType);
|
||||
var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, supportMultiEditions, parseName, parent.ContainingFolderPath);
|
||||
|
||||
var result = new MultiItemResolverResult
|
||||
{
|
||||
@@ -305,7 +302,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
ProductionYear = video.Year,
|
||||
Name = parseName ? video.Name : firstVideo.Name,
|
||||
AdditionalParts = additionalParts,
|
||||
LocalAlternateVersions = video.AlternateVersions.Select(av => av.Files[0].Path).ToArray()
|
||||
LocalAlternateVersions = video.AlternateVersions.Select(i => i.Path).ToArray()
|
||||
};
|
||||
|
||||
SetVideoType(videoItem, firstVideo);
|
||||
@@ -334,13 +331,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
|
||||
for (var j = 0; j < current.AlternateVersions.Count; j++)
|
||||
{
|
||||
var alternate = current.AlternateVersions[j];
|
||||
for (var k = 0; k < alternate.Files.Count; k++)
|
||||
if (ContainsFile(current.AlternateVersions[j], file))
|
||||
{
|
||||
if (ContainsFile(alternate.Files[k], file))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,25 +369,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
// We need to only look at the name of this actual item (not parents)
|
||||
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
||||
|
||||
// The fallback filename is only used when the item isn't in a mixed folder
|
||||
var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan());
|
||||
var tmdbid = justName.GetAttributeValue("tmdbid");
|
||||
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
|
||||
item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid"));
|
||||
|
||||
string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute)
|
||||
// If not in a mixed folder and ID not found in folder path, check filename
|
||||
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
|
||||
{
|
||||
var id = name.GetAttributeValue(attribute);
|
||||
|
||||
// If not in a mixed folder and ID not found in folder path, check filename
|
||||
if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder)
|
||||
{
|
||||
id = fallbackName.GetAttributeValue(attribute);
|
||||
}
|
||||
|
||||
return id;
|
||||
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
|
||||
}
|
||||
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
|
||||
|
||||
if (!string.IsNullOrEmpty(item.Path))
|
||||
{
|
||||
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Emby.Server.Implementations.Playlists;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -47,30 +45,8 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
};
|
||||
}
|
||||
|
||||
// Anything directly inside the internal playlists folder is a playlist, even when its
|
||||
// playlist.xml is missing: failing to resolve here makes the library scan treat the
|
||||
// playlist as deleted from disk and remove it, taking its items with it.
|
||||
if (args.Parent is PlaylistsFolder)
|
||||
{
|
||||
return new Playlist
|
||||
{
|
||||
Path = args.Path,
|
||||
Name = filename,
|
||||
OpenAccess = true
|
||||
};
|
||||
}
|
||||
|
||||
// It's a directory-based playlist if the directory contains a playlist file
|
||||
IEnumerable<string> filePaths;
|
||||
try
|
||||
{
|
||||
filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
|
||||
if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return new Playlist
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Emby.Naming.Common;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -83,34 +81,10 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
episode.ParentIndexNumber = 1;
|
||||
}
|
||||
|
||||
SetProviderIdFromPath(episode, args.Path);
|
||||
|
||||
return episode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets provider ids from the episode file name.
|
||||
/// </summary>
|
||||
/// <param name="item">The episode.</param>
|
||||
/// <param name="path">The episode file path.</param>
|
||||
private static void SetProviderIdFromPath(Episode item, string path)
|
||||
{
|
||||
var justName = Path.GetFileNameWithoutExtension(path.AsSpan());
|
||||
|
||||
var imdbId = justName.GetAttributeValue("imdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Imdb, imdbId);
|
||||
|
||||
var tvdbId = justName.GetAttributeValue("tvdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
|
||||
|
||||
var tvmazeId = justName.GetAttributeValue("tvmazeid");
|
||||
item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
|
||||
|
||||
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Emby.Naming.Common;
|
||||
using Emby.Naming.TV;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -82,14 +77,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var hasAnyVideo = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
|
||||
.Any(file => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(file)));
|
||||
|
||||
if (!hasAnyVideo)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name))
|
||||
@@ -99,47 +86,15 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
args.LibraryOptions.SeasonZeroDisplayName :
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localization.GetServerLocalizedString("NameSeasonNumber"),
|
||||
_localization.GetLocalizedString("NameSeasonNumber"),
|
||||
seasonNumber,
|
||||
args.LibraryOptions.PreferredMetadataLanguage);
|
||||
}
|
||||
|
||||
SetProviderIdFromPath(season, path);
|
||||
|
||||
return season;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets provider ids from the season folder name.
|
||||
/// </summary>
|
||||
/// <param name="item">The season.</param>
|
||||
/// <param name="path">The season folder path.</param>
|
||||
private static void SetProviderIdFromPath(Season item, string path)
|
||||
{
|
||||
var justName = Path.GetFileName(path.AsSpan());
|
||||
|
||||
var tvdbId = justName.GetAttributeValue("tvdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
|
||||
|
||||
var tvmazeId = justName.GetAttributeValue("tvmazeid");
|
||||
item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
|
||||
|
||||
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
|
||||
|
||||
// Anime databases model a single cour as its own entry, so a multi-season
|
||||
// series maps to one of these ids per season rather than one per series.
|
||||
var anidbId = justName.GetAttributeValue("anidbid");
|
||||
item.TrySetProviderId("AniDB", anidbId);
|
||||
|
||||
var aniListId = justName.GetAttributeValue("anilistid");
|
||||
item.TrySetProviderId("AniList", aniListId);
|
||||
|
||||
var aniSearchId = justName.GetAttributeValue("anisearchid");
|
||||
item.TrySetProviderId("AniSearch", aniSearchId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="namingOptions">The naming options.</param>
|
||||
public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions)
|
||||
public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions)
|
||||
{
|
||||
_logger = logger;
|
||||
_namingOptions = namingOptions;
|
||||
@@ -57,11 +57,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
return null;
|
||||
}
|
||||
|
||||
if (args.Parent is not null && args.Parent.IsRoot)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
|
||||
|
||||
var collectionType = args.GetCollectionType();
|
||||
@@ -74,8 +69,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
return new Series
|
||||
{
|
||||
Path = args.Path,
|
||||
Name = seriesInfo.Name,
|
||||
ProductionYear = seriesInfo.Year
|
||||
Name = seriesInfo.Name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Manages search providers and orchestrates search operations.
|
||||
/// </summary>
|
||||
public class SearchManager : ISearchManager
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private readonly ILogger<SearchManager> _logger;
|
||||
private IExternalSearchProvider[] _externalProviders = [];
|
||||
private IInternalSearchProvider[] _internalProviders = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public SearchManager(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers,
|
||||
ILogger<SearchManager> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddParts(IEnumerable<ISearchProvider> providers)
|
||||
{
|
||||
var allProviders = providers.OrderBy(p => p.Priority).ToArray();
|
||||
|
||||
_externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray();
|
||||
_internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}",
|
||||
_externalProviders.Length,
|
||||
string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")),
|
||||
string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})")));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<ISearchProvider> GetProviders()
|
||||
{
|
||||
return [.. _externalProviders, .. _internalProviders];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||
|
||||
var searchTerm = query.SearchTerm.Trim().RemoveDiacritics();
|
||||
|
||||
var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken);
|
||||
var internalTask = _internalProviders.Length > 0
|
||||
? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken)
|
||||
: Task.FromResult<IReadOnlyList<SearchResult>>([]);
|
||||
|
||||
await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
|
||||
|
||||
var externalResults = await externalTask.ConfigureAwait(false);
|
||||
|
||||
// Internal providers apply user-access filtering inline in their queries. External
|
||||
// providers don't know about user permissions, so they may return IDs from hidden
|
||||
// libraries or items the user is otherwise blocked from. Filter them here to close
|
||||
// that gap. The Items controller's second roundtrip via folder.GetItems applies most
|
||||
// of these again, but it does not restrict by TopParentIds when ItemIds is set.
|
||||
if (externalResults.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(query.UserId.Value);
|
||||
if (user is not null)
|
||||
{
|
||||
externalResults = await FilterByUserAccessAsync(externalResults, user, query, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (externalResults.Count > 0)
|
||||
{
|
||||
return externalResults;
|
||||
}
|
||||
|
||||
if (_internalProviders.Length > 0)
|
||||
{
|
||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||
}
|
||||
|
||||
return await internalTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||
IReadOnlyList<SearchResult> candidates,
|
||||
User user,
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds
|
||||
// for the user's accessible libraries. The candidate ids are applied to the query below
|
||||
// rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when
|
||||
// ItemIds is non-empty.
|
||||
var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager);
|
||||
|
||||
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.WhereOneOrMany(candidateIds, e => e.Id);
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (allowedIds.Count == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
|
||||
if (filtered.Count < candidates.Count)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Dropped {Dropped} of {Total} search candidates due to user access filtering",
|
||||
candidates.Count - filtered.Count,
|
||||
candidates.Count);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||
|
||||
var providerQuery = BuildProviderQuery(query);
|
||||
var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return new QueryResult<SearchHintInfo>();
|
||||
}
|
||||
|
||||
var candidateScores = BuildScoreLookup(candidates);
|
||||
var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId);
|
||||
|
||||
var excludeItemTypes = BuildExcludeItemTypes(query);
|
||||
var includeItemTypes = BuildIncludeItemTypes(query);
|
||||
|
||||
var internalQuery = new InternalItemsQuery(user)
|
||||
{
|
||||
ItemIds = candidateScores.Keys.ToArray(),
|
||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||
IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [],
|
||||
MediaTypes = query.MediaTypes.ToArray(),
|
||||
IncludeItemsByName = !query.ParentId.HasValue,
|
||||
ParentId = query.ParentId ?? Guid.Empty,
|
||||
Recursive = true,
|
||||
IsKids = query.IsKids,
|
||||
IsMovie = query.IsMovie,
|
||||
IsNews = query.IsNews,
|
||||
IsSeries = query.IsSeries,
|
||||
IsSports = query.IsSports,
|
||||
DtoOptions = new DtoOptions
|
||||
{
|
||||
Fields =
|
||||
[
|
||||
ItemFields.AirTime,
|
||||
ItemFields.DateCreated,
|
||||
ItemFields.ChannelInfo,
|
||||
ItemFields.ParentId
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name
|
||||
// rather than being stored as regular library items. They require special handling:
|
||||
// 1. Convert ParentId to AncestorIds (to filter by library folder)
|
||||
// 2. Set IncludeItemsByName = true (to include these virtual items in results)
|
||||
// 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally)
|
||||
// 4. Use GetAllArtists() instead of GetItemList() to query the artist index
|
||||
IReadOnlyList<BaseItem> items;
|
||||
if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
|
||||
{
|
||||
if (!internalQuery.ParentId.IsEmpty())
|
||||
{
|
||||
internalQuery.AncestorIds = [internalQuery.ParentId];
|
||||
internalQuery.ParentId = Guid.Empty;
|
||||
}
|
||||
|
||||
internalQuery.IncludeItemsByName = true;
|
||||
internalQuery.IncludeItemTypes = [];
|
||||
items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
items = _libraryManager.GetItemList(internalQuery);
|
||||
}
|
||||
|
||||
var orderedResults = items
|
||||
.Select(item => new SearchHintInfo { Item = item })
|
||||
.OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f))
|
||||
.ToList();
|
||||
|
||||
var totalCount = orderedResults.Count;
|
||||
|
||||
if (query.StartIndex.HasValue)
|
||||
{
|
||||
orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList();
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
orderedResults = orderedResults.Take(query.Limit.Value).ToList();
|
||||
}
|
||||
|
||||
return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync(
|
||||
IEnumerable<ISearchProvider> providers,
|
||||
SearchProviderQuery providerQuery,
|
||||
string searchTerm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestedLimit = providerQuery.Limit ?? 100;
|
||||
var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray();
|
||||
if (applicable.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var perProvider = await Task.WhenAll(
|
||||
applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken)))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var bestScores = new Dictionary<Guid, float>();
|
||||
foreach (var providerResults in perProvider)
|
||||
{
|
||||
foreach (var result in providerResults)
|
||||
{
|
||||
UpdateBestScore(bestScores, result);
|
||||
}
|
||||
}
|
||||
|
||||
return bestScores
|
||||
.Select(kvp => new SearchResult(kvp.Key, kvp.Value))
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Take(requestedLimit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync(
|
||||
ISearchProvider provider,
|
||||
SearchProviderQuery providerQuery,
|
||||
string searchTerm,
|
||||
int requestedLimit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = provider is IExternalSearchProvider externalProvider
|
||||
? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false)
|
||||
: await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
|
||||
provider.Name,
|
||||
results.Count,
|
||||
searchTerm);
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync(
|
||||
IExternalSearchProvider provider,
|
||||
SearchProviderQuery providerQuery,
|
||||
int requestedLimit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<SearchResult>();
|
||||
await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(result);
|
||||
if (results.Count >= requestedLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result)
|
||||
{
|
||||
if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore)
|
||||
{
|
||||
bestScores[result.ItemId] = result.Score;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results)
|
||||
{
|
||||
var lookup = new Dictionary<Guid, float>(results.Count);
|
||||
foreach (var result in results)
|
||||
{
|
||||
lookup[result.ItemId] = result.Score;
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static SearchProviderQuery BuildProviderQuery(SearchQuery query)
|
||||
{
|
||||
var excludeItemTypes = BuildExcludeItemTypes(query);
|
||||
var includeItemTypes = BuildIncludeItemTypes(query);
|
||||
|
||||
// Remove any excluded types from includes
|
||||
if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0)
|
||||
{
|
||||
includeItemTypes.RemoveAll(excludeItemTypes.Contains);
|
||||
}
|
||||
|
||||
return new SearchProviderQuery
|
||||
{
|
||||
SearchTerm = query.SearchTerm,
|
||||
UserId = query.UserId.IsEmpty() ? null : query.UserId,
|
||||
IncludeItemTypes = includeItemTypes.ToArray(),
|
||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||
MediaTypes = query.MediaTypes.ToArray(),
|
||||
Limit = query.Limit,
|
||||
ParentId = query.ParentId
|
||||
};
|
||||
}
|
||||
|
||||
private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query)
|
||||
{
|
||||
var excludeItemTypes = query.ExcludeItemTypes.ToList();
|
||||
|
||||
excludeItemTypes.Add(BaseItemKind.Year);
|
||||
excludeItemTypes.Add(BaseItemKind.Folder);
|
||||
excludeItemTypes.Add(BaseItemKind.CollectionFolder);
|
||||
|
||||
if (!query.IncludeGenres)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
|
||||
if (!query.IncludePeople)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
|
||||
if (!query.IncludeStudios)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
|
||||
if (!query.IncludeArtists)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
|
||||
return excludeItemTypes;
|
||||
}
|
||||
|
||||
private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query)
|
||||
{
|
||||
var includeItemTypes = query.IncludeItemTypes.ToList();
|
||||
if (query.IncludeMedia)
|
||||
{
|
||||
return includeItemTypes;
|
||||
}
|
||||
|
||||
if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre))
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
|
||||
if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person))
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
|
||||
if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio))
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
|
||||
if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist))
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
|
||||
return includeItemTypes;
|
||||
}
|
||||
|
||||
private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value)
|
||||
=> list.Count == 0 || list.Contains(value);
|
||||
|
||||
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
|
||||
{
|
||||
if (!list.Contains(value))
|
||||
{
|
||||
list.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the access filter that decides which items a search may return for a user.
|
||||
/// </summary>
|
||||
internal static class SearchQueryAccessFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an access filter carrying the search's library access and type filters.
|
||||
/// </summary>
|
||||
/// <param name="user">The user the search runs for.</param>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <returns>The access filter.</returns>
|
||||
public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager)
|
||||
{
|
||||
// The type filters have to travel with the access filter: a by-name item belongs to no
|
||||
// library, so it carries no TopParentId to match, and the library filter only knows to
|
||||
// exempt it when the query says those types are wanted. A search scoped to a parent gets
|
||||
// no exemption because a by-name item has no parent to descend from either.
|
||||
var accessFilter = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = query.IncludeItemTypes,
|
||||
ExcludeItemTypes = query.ExcludeItemTypes,
|
||||
IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty()
|
||||
};
|
||||
|
||||
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
|
||||
libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
return accessFilter;
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in SQL-based search provider that queries the library database directly.
|
||||
/// </summary>
|
||||
public class SqlSearchProvider : IInternalSearchProvider
|
||||
{
|
||||
private const int DefaultSearchLimit = 100;
|
||||
private const float ExactMatchScore = 100f;
|
||||
private const float PrefixMatchScore = 80f;
|
||||
private const float WordPrefixMatchScore = 75f;
|
||||
private const float ContainsMatchScore = 50f;
|
||||
|
||||
private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemTypeLookup _itemTypeLookup;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqlSearchProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="itemTypeLookup">The item type lookup.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||
public SqlSearchProvider(
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemTypeLookup itemTypeLookup,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IItemQueryHelpers queryHelpers)
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_itemTypeLookup = itemTypeLookup;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_queryHelpers = queryHelpers;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "Database";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MetadataPluginType Type => MetadataPluginType.SearchProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int Priority => 100; // Low priority - runs as fallback
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool CanSearch(SearchProviderQuery query)
|
||||
{
|
||||
// SQL search can always handle any query
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||
|
||||
var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics();
|
||||
if (string.IsNullOrEmpty(rawSearchTerm))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var cleanSearchTerm = rawSearchTerm.GetCleanValue();
|
||||
if (string.IsNullOrEmpty(cleanSearchTerm))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var cleanPrefix = cleanSearchTerm + " ";
|
||||
// OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName,
|
||||
// so match it via a case-insensitive LIKE rather than a per-row case conversion
|
||||
// that may not translate to SQL on every provider.
|
||||
var likeOriginal = $"%{rawSearchTerm}%";
|
||||
var limit = query.Limit ?? DefaultSearchLimit;
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
// Lightweight projection: select only what's needed to score and identify items.
|
||||
var dbQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Id != _placeholderId)
|
||||
.Where(e => !e.IsVirtualItem)
|
||||
.Where(e => e.CleanName!.Contains(cleanSearchTerm)
|
||||
|| (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));
|
||||
|
||||
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
|
||||
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
|
||||
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
|
||||
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
|
||||
|
||||
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
|
||||
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
|
||||
// directly without any per-row case conversion. Items that match only via
|
||||
// OriginalTitle fall through to the Contains tier.
|
||||
// Tie-break by Id for deterministic ordering so the explicit OrderBy + Take
|
||||
// satisfies EF Core's row-limiting-with-OrderBy requirement.
|
||||
var scored = dbQuery.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
Score =
|
||||
(e.CleanName == cleanSearchTerm) ? ExactMatchScore
|
||||
: e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore
|
||||
: e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore
|
||||
: ContainsMatchScore
|
||||
});
|
||||
|
||||
return await scored
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(limit)
|
||||
.Select(x => new SearchResult(x.Id, x.Score))
|
||||
.ToArrayAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyTypeFilter(
|
||||
IQueryable<BaseItemEntity> query,
|
||||
BaseItemKind[] includeItemTypes,
|
||||
BaseItemKind[] excludeItemTypes)
|
||||
{
|
||||
if (includeItemTypes.Length > 0)
|
||||
{
|
||||
var includeTypeNames = MapKindsToTypeNames(includeItemTypes);
|
||||
if (includeTypeNames.Count > 0)
|
||||
{
|
||||
query = query.Where(e => includeTypeNames.Contains(e.Type));
|
||||
}
|
||||
}
|
||||
else if (excludeItemTypes.Length > 0)
|
||||
{
|
||||
var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes);
|
||||
if (excludeTypeNames.Count > 0)
|
||||
{
|
||||
query = query.Where(e => !excludeTypeNames.Contains(e.Type));
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter(
|
||||
IQueryable<BaseItemEntity> query,
|
||||
MediaType[] mediaTypes)
|
||||
{
|
||||
if (mediaTypes.Length == 0)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray();
|
||||
return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType));
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ApplyParentFilter(
|
||||
IQueryable<BaseItemEntity> query,
|
||||
Guid? parentId)
|
||||
{
|
||||
if (!parentId.HasValue || parentId.Value.IsEmpty())
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var pid = parentId.Value;
|
||||
return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
|
||||
}
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
|
||||
JellyfinDbContext dbContext,
|
||||
IQueryable<BaseItemEntity> query,
|
||||
SearchProviderQuery searchQuery)
|
||||
{
|
||||
var userId = searchQuery.UserId;
|
||||
if (!userId.HasValue || userId.Value.IsEmpty())
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager);
|
||||
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
|
||||
}
|
||||
|
||||
private List<string> MapKindsToTypeNames(BaseItemKind[] kinds)
|
||||
{
|
||||
var list = new List<string>(kinds.Length);
|
||||
foreach (var kind in kinds)
|
||||
{
|
||||
if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null)
|
||||
{
|
||||
list.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Search;
|
||||
|
||||
namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
public class SearchEngine : ISearchEngine
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
public SearchEngine(ILibraryManager libraryManager, IUserManager userManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query)
|
||||
{
|
||||
User? user = null;
|
||||
if (!query.UserId.IsEmpty())
|
||||
{
|
||||
user = _userManager.GetUserById(query.UserId);
|
||||
}
|
||||
|
||||
var results = GetSearchHints(query, user);
|
||||
var totalRecordCount = results.Count;
|
||||
|
||||
if (query.StartIndex.HasValue)
|
||||
{
|
||||
results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count));
|
||||
}
|
||||
|
||||
return new QueryResult<SearchHintInfo>(
|
||||
query.StartIndex,
|
||||
totalRecordCount,
|
||||
results);
|
||||
}
|
||||
|
||||
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
|
||||
{
|
||||
if (!list.Contains(value))
|
||||
{
|
||||
list.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the search hints.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>IEnumerable{SearchHintResult}.</returns>
|
||||
/// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception>
|
||||
private List<SearchHintInfo> GetSearchHints(SearchQuery query, User? user)
|
||||
{
|
||||
var searchTerm = query.SearchTerm;
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(searchTerm);
|
||||
|
||||
searchTerm = searchTerm.Trim().RemoveDiacritics();
|
||||
|
||||
var excludeItemTypes = query.ExcludeItemTypes.ToList();
|
||||
var includeItemTypes = query.IncludeItemTypes.ToList();
|
||||
|
||||
excludeItemTypes.Add(BaseItemKind.Year);
|
||||
excludeItemTypes.Add(BaseItemKind.Folder);
|
||||
|
||||
if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
|
||||
if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
|
||||
if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
|
||||
if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder);
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Folder);
|
||||
var mediaTypes = query.MediaTypes.ToList();
|
||||
|
||||
if (includeItemTypes.Count > 0)
|
||||
{
|
||||
excludeItemTypes.Clear();
|
||||
mediaTypes.Clear();
|
||||
}
|
||||
|
||||
var searchQuery = new InternalItemsQuery(user)
|
||||
{
|
||||
SearchTerm = searchTerm,
|
||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||
IncludeItemTypes = includeItemTypes.ToArray(),
|
||||
Limit = query.Limit,
|
||||
IncludeItemsByName = !query.ParentId.HasValue,
|
||||
ParentId = query.ParentId ?? Guid.Empty,
|
||||
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
|
||||
Recursive = true,
|
||||
|
||||
IsKids = query.IsKids,
|
||||
IsMovie = query.IsMovie,
|
||||
IsNews = query.IsNews,
|
||||
IsSeries = query.IsSeries,
|
||||
IsSports = query.IsSports,
|
||||
MediaTypes = mediaTypes.ToArray(),
|
||||
|
||||
DtoOptions = new DtoOptions
|
||||
{
|
||||
Fields = new ItemFields[]
|
||||
{
|
||||
ItemFields.AirTime,
|
||||
ItemFields.DateCreated,
|
||||
ItemFields.ChannelInfo,
|
||||
ItemFields.ParentId
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
IReadOnlyList<BaseItem> mediaItems;
|
||||
|
||||
if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
|
||||
{
|
||||
if (!searchQuery.ParentId.IsEmpty())
|
||||
{
|
||||
searchQuery.AncestorIds = [searchQuery.ParentId];
|
||||
searchQuery.ParentId = Guid.Empty;
|
||||
}
|
||||
|
||||
searchQuery.IncludeItemsByName = true;
|
||||
searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>();
|
||||
mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
mediaItems = _libraryManager.GetItemList(searchQuery);
|
||||
}
|
||||
|
||||
return mediaItems.Select(i => new SearchHintInfo
|
||||
{
|
||||
Item = i
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Provides similar items for audio tracks.
|
||||
/// </summary>
|
||||
public class AudioSimilarItemsProvider : ILocalSimilarItemsProvider<Audio>
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AudioSimilarItemsProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
public AudioSimilarItemsProvider(ILibraryManager libraryManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "Local Genre/Tag";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Audio item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var internalQuery = new InternalItemsQuery(query.User)
|
||||
{
|
||||
Genres = item.Genres,
|
||||
Tags = item.Tags,
|
||||
Limit = query.Limit,
|
||||
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||
ExcludeArtistIds = [.. query.ExcludeArtistIds],
|
||||
IncludeItemTypes = [BaseItemKind.Audio],
|
||||
EnableGroupByMetadataKey = false,
|
||||
EnableTotalRecordCount = true,
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||
};
|
||||
|
||||
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Provides similar items for Live TV programs.
|
||||
/// </summary>
|
||||
public class LiveTvProgramSimilarItemsProvider : ILocalSimilarItemsProvider<LiveTvProgram>
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LiveTvProgramSimilarItemsProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
public LiveTvProgramSimilarItemsProvider(
|
||||
ILibraryManager libraryManager,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "Local Genre/Tag";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(LiveTvProgram item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
BaseItemKind[] includeItemTypes;
|
||||
bool enableGroupByMetadataKey;
|
||||
bool enableTotalRecordCount;
|
||||
|
||||
if (item.IsMovie)
|
||||
{
|
||||
// Movie-like program
|
||||
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||
|
||||
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||
{
|
||||
itemTypes.Add(BaseItemKind.Trailer);
|
||||
itemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||
}
|
||||
|
||||
includeItemTypes = [.. itemTypes];
|
||||
enableGroupByMetadataKey = true;
|
||||
enableTotalRecordCount = false;
|
||||
}
|
||||
else if (item.IsSeries)
|
||||
{
|
||||
// Series-like program
|
||||
includeItemTypes = [BaseItemKind.Series];
|
||||
enableGroupByMetadataKey = false;
|
||||
enableTotalRecordCount = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default - match same type
|
||||
includeItemTypes = [item.GetBaseItemKind()];
|
||||
enableGroupByMetadataKey = false;
|
||||
enableTotalRecordCount = true;
|
||||
}
|
||||
|
||||
var internalQuery = new InternalItemsQuery(query.User)
|
||||
{
|
||||
Genres = item.Genres,
|
||||
Tags = item.Tags,
|
||||
Limit = query.Limit,
|
||||
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
EnableGroupByMetadataKey = enableGroupByMetadataKey,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||
};
|
||||
|
||||
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||
}
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Provides similar items for movies and trailers using weighted scoring.
|
||||
/// </summary>
|
||||
public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, IBatchLocalSimilarItemsProvider
|
||||
{
|
||||
private const int GenreWeight = 10;
|
||||
private const int TagWeight = 5;
|
||||
private const int StudioWeight = 5;
|
||||
private const int DirectorWeight = 50;
|
||||
private const int ActorWeight = 15;
|
||||
|
||||
// Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id
|
||||
// load, navigation includes) stay bounded regardless of caller input.
|
||||
private const int MaxBatchSourceItems = 64;
|
||||
|
||||
private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions =
|
||||
[
|
||||
(ItemValueType.Genre, GenreWeight),
|
||||
(ItemValueType.Tags, TagWeight),
|
||||
(ItemValueType.Studios, StudioWeight)
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, int> _personTypeWeights = new(StringComparer.Ordinal)
|
||||
{
|
||||
[nameof(PersonKind.Director)] = DirectorWeight,
|
||||
[nameof(PersonKind.Actor)] = ActorWeight,
|
||||
[nameof(PersonKind.GuestStar)] = ActorWeight,
|
||||
};
|
||||
|
||||
private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys];
|
||||
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared query helpers.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
public MovieSimilarItemsProvider(
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "Local Genre/Tag";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
|
||||
return results.TryGetValue(item.Id, out var items) ? items : [];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
|
||||
return results.TryGetValue(item.Id, out var items) ? items : [];
|
||||
}
|
||||
|
||||
bool ILocalSimilarItemsProvider.Supports(Type itemType)
|
||||
=> typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType);
|
||||
|
||||
Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
=> item switch
|
||||
{
|
||||
Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken),
|
||||
Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken),
|
||||
_ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
|
||||
IReadOnlyList<BaseItemDto> sourceItems,
|
||||
SimilarItemsQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||
{
|
||||
includeItemTypes.Add(BaseItemKind.Trailer);
|
||||
includeItemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||
}
|
||||
|
||||
var limit = query.Limit ?? 50;
|
||||
var dtoOptions = query.DtoOptions ?? new DtoOptions();
|
||||
|
||||
if (sourceItems.Count > MaxBatchSourceItems)
|
||||
{
|
||||
sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList();
|
||||
}
|
||||
|
||||
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
// Phase 1: Score all candidates per source item
|
||||
var sourceIds = sourceItems.Select(i => i.Id).ToList();
|
||||
var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var allCandidateIds = new HashSet<Guid>();
|
||||
foreach (var (_, scores) in perSourceScores)
|
||||
{
|
||||
allCandidateIds.UnionWith(
|
||||
scores.OrderByDescending(kvp => kvp.Value)
|
||||
.Take(limit * 3)
|
||||
.Select(kvp => kvp.Key));
|
||||
}
|
||||
|
||||
var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
|
||||
if (allCandidateIds.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Phase 2: One access filter for all candidates
|
||||
var filter = new InternalItemsQuery(query.User)
|
||||
{
|
||||
IncludeItemTypes = [.. includeItemTypes],
|
||||
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||
DtoOptions = dtoOptions,
|
||||
EnableGroupByMetadataKey = true,
|
||||
EnableTotalRecordCount = false,
|
||||
IsMovie = true,
|
||||
IsPlayed = false
|
||||
};
|
||||
|
||||
if (query.User is not null)
|
||||
{
|
||||
_libraryManager.ConfigureUserAccess(filter, query.User);
|
||||
}
|
||||
|
||||
_queryHelpers.PrepareFilterQuery(filter);
|
||||
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
|
||||
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
|
||||
|
||||
var allCandidateIdsList = allCandidateIds.ToList();
|
||||
var accessibleItems = await baseQuery
|
||||
.WhereOneOrMany(allCandidateIdsList, e => e.Id)
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
|
||||
var allOrderedIds = new HashSet<Guid>();
|
||||
var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>();
|
||||
|
||||
foreach (var item in sourceItems)
|
||||
{
|
||||
if (!perSourceScores.TryGetValue(item.Id, out var scores))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var orderedIds = accessibleItems
|
||||
.Where(x => scores.ContainsKey(x.Id))
|
||||
.OrderByDescending(x => scores.GetValueOrDefault(x.Id))
|
||||
.DistinctBy(x => x.PresentationUniqueKey)
|
||||
.Take(limit)
|
||||
.Select(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
if (orderedIds.Count > 0)
|
||||
{
|
||||
perSourceOrderedIds[item.Id] = orderedIds;
|
||||
allOrderedIds.UnionWith(orderedIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (allOrderedIds.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Phase 4: One entity load for all results
|
||||
var allOrderedIdsList = allOrderedIds.ToList();
|
||||
var entities = await _queryHelpers.ApplyNavigations(
|
||||
context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id),
|
||||
filter)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entitiesById = entities
|
||||
.Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization))
|
||||
.Where(dto => dto is not null)
|
||||
.ToDictionary(i => i!.Id);
|
||||
|
||||
// Phase 5: Split by source, preserving score order
|
||||
foreach (var (sourceId, orderedIds) in perSourceOrderedIds)
|
||||
{
|
||||
var items = orderedIds
|
||||
.Where(entitiesById.ContainsKey)
|
||||
.Select(id => entitiesById[id]!)
|
||||
.ToList();
|
||||
|
||||
if (items.Count > 0)
|
||||
{
|
||||
result[sourceId] = items;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, JellyfinDbContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new Dictionary<Guid, Dictionary<Guid, int>>();
|
||||
foreach (var id in sourceIds)
|
||||
{
|
||||
result[id] = [];
|
||||
}
|
||||
|
||||
foreach (var (valueType, weight) in _itemValueDimensions)
|
||||
{
|
||||
var sourceRows = await context.ItemValuesMap.AsNoTracking()
|
||||
.Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType)
|
||||
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet());
|
||||
var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList();
|
||||
if (allKeys.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateRows = await context.ItemValuesMap.AsNoTracking()
|
||||
.Where(m => !m.Item.PrimaryVersionId.HasValue && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
|
||||
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
|
||||
ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result);
|
||||
}
|
||||
|
||||
var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking()
|
||||
.Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType))
|
||||
.Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (personSourceRows.Count > 0)
|
||||
{
|
||||
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
|
||||
.Where(m => !m.Item.PrimaryVersionId.HasValue)
|
||||
.Where(m => context.PeopleBaseItemMap
|
||||
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
|
||||
.Select(s => s.PeopleId)
|
||||
.Contains(m.PeopleId))
|
||||
.Select(m => new { m.ItemId, m.PeopleId })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var personToCandidates = personCandidateRows
|
||||
.GroupBy(r => r.PeopleId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
|
||||
|
||||
foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!]))
|
||||
{
|
||||
var sourceMap = weightGroup
|
||||
.GroupBy(r => r.ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet());
|
||||
ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var sourceId in sourceIds)
|
||||
{
|
||||
var scoreMap = result[sourceId];
|
||||
scoreMap.Remove(sourceId);
|
||||
if (scoreMap.Count == 0)
|
||||
{
|
||||
result.Remove(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyDimensionScores<TKey>(
|
||||
List<Guid> sourceIds,
|
||||
Dictionary<Guid, HashSet<TKey>> sourceMap,
|
||||
Dictionary<TKey, List<Guid>> keyToCandidates,
|
||||
int weight,
|
||||
Dictionary<Guid, Dictionary<Guid, int>> result)
|
||||
where TKey : notnull
|
||||
{
|
||||
foreach (var sourceId in sourceIds)
|
||||
{
|
||||
if (!sourceMap.TryGetValue(sourceId, out var sourceKeys))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scoreMap = result[sourceId];
|
||||
foreach (var key in sourceKeys)
|
||||
{
|
||||
if (!keyToCandidates.TryGetValue(key, out var candidates))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidateId in candidates)
|
||||
{
|
||||
scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Provides similar items for music albums.
|
||||
/// </summary>
|
||||
public class MusicAlbumSimilarItemsProvider : ILocalSimilarItemsProvider<MusicAlbum>
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MusicAlbumSimilarItemsProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
public MusicAlbumSimilarItemsProvider(ILibraryManager libraryManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "Local Genre/Tag";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicAlbum item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var internalQuery = new InternalItemsQuery(query.User)
|
||||
{
|
||||
Genres = item.Genres,
|
||||
Tags = item.Tags,
|
||||
Limit = query.Limit,
|
||||
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||
ExcludeArtistIds = [.. query.ExcludeArtistIds],
|
||||
IncludeItemTypes = [BaseItemKind.MusicAlbum],
|
||||
EnableGroupByMetadataKey = false,
|
||||
EnableTotalRecordCount = true,
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||
};
|
||||
|
||||
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Provides similar items for music artists.
|
||||
/// </summary>
|
||||
public class MusicArtistSimilarItemsProvider : ILocalSimilarItemsProvider<MusicArtist>
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MusicArtistSimilarItemsProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
public MusicArtistSimilarItemsProvider(ILibraryManager libraryManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "Local Genre/Tag";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicArtist item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var internalQuery = new InternalItemsQuery(query.User)
|
||||
{
|
||||
Genres = item.Genres,
|
||||
Tags = item.Tags,
|
||||
Limit = query.Limit,
|
||||
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||
ExcludeArtistIds = [.. query.ExcludeArtistIds],
|
||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||
EnableGroupByMetadataKey = false,
|
||||
EnableTotalRecordCount = true,
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||
};
|
||||
|
||||
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Provides similar items for TV series.
|
||||
/// </summary>
|
||||
public class SeriesSimilarItemsProvider : ILocalSimilarItemsProvider<Series>
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SeriesSimilarItemsProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
public SeriesSimilarItemsProvider(ILibraryManager libraryManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "Local Genre/Tag";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Series item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var internalQuery = new InternalItemsQuery(query.User)
|
||||
{
|
||||
Genres = item.Genres,
|
||||
Tags = item.Tags,
|
||||
Limit = query.Limit,
|
||||
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||
IncludeItemTypes = [BaseItemKind.Series],
|
||||
EnableGroupByMetadataKey = false,
|
||||
EnableTotalRecordCount = true,
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||
};
|
||||
|
||||
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the access filter that decides which items a similar-items lookup may return for a user.
|
||||
/// </summary>
|
||||
internal static class SimilarItemsAccessFilter
|
||||
{
|
||||
private static readonly BaseItemKind[] _itemByNameKinds =
|
||||
[
|
||||
BaseItemKind.Person,
|
||||
BaseItemKind.Genre,
|
||||
BaseItemKind.MusicGenre,
|
||||
BaseItemKind.MusicArtist,
|
||||
BaseItemKind.Studio
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Builds an access filter carrying the user's library access and parental restrictions.
|
||||
/// </summary>
|
||||
/// <param name="user">The user the lookup runs for.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <returns>The access filter.</returns>
|
||||
public static InternalItemsQuery Build(User user, ILibraryManager libraryManager)
|
||||
{
|
||||
// IncludeItemTypes is read only for the by-name exemption here; the caller applies this
|
||||
// filter through ApplyAccessFiltering, which does not translate it into a type restriction.
|
||||
var accessFilter = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = _itemByNameKinds
|
||||
};
|
||||
|
||||
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
|
||||
libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
return accessFilter;
|
||||
}
|
||||
}
|
||||
@@ -1,724 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Manages similar items providers and orchestrates similar items operations.
|
||||
/// </summary>
|
||||
public class SimilarItemsManager : ISimilarItemsManager
|
||||
{
|
||||
private readonly ILogger<SimilarItemsManager> _logger;
|
||||
private readonly IServerApplicationPaths _appPaths;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private ISimilarItemsProvider[] _similarItemsProviders = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SimilarItemsManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="appPaths">The server application paths.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="fileSystem">The file system.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||
public SimilarItemsManager(
|
||||
ILogger<SimilarItemsManager> logger,
|
||||
IServerApplicationPaths appPaths,
|
||||
ILibraryManager libraryManager,
|
||||
IFileSystem fileSystem,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers)
|
||||
{
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
_libraryManager = libraryManager;
|
||||
_fileSystem = fileSystem;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddParts(IEnumerable<ISimilarItemsProvider> providers)
|
||||
{
|
||||
_similarItemsProviders = providers.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>()
|
||||
where T : BaseItem
|
||||
{
|
||||
var itemType = typeof(T);
|
||||
return _similarItemsProviders
|
||||
.Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType))
|
||||
|| (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(
|
||||
BaseItem item,
|
||||
IReadOnlyList<Guid> excludeArtistIds,
|
||||
User? user,
|
||||
DtoOptions dtoOptions,
|
||||
int? limit,
|
||||
LibraryOptions? libraryOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ArgumentNullException.ThrowIfNull(excludeArtistIds);
|
||||
|
||||
var itemType = item.GetType();
|
||||
var requestedLimit = limit ?? 50;
|
||||
var itemKind = item.GetBaseItemKind();
|
||||
|
||||
// Ensure ProviderIds is included in DtoOptions for matching remote provider responses
|
||||
if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds))
|
||||
{
|
||||
dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray();
|
||||
}
|
||||
|
||||
// Local providers are always enabled. Remote providers must be explicitly enabled.
|
||||
var localProviders = _similarItemsProviders
|
||||
.OfType<ILocalSimilarItemsProvider>()
|
||||
.Where(p => p.Supports(itemType))
|
||||
.ToList();
|
||||
var remoteProviders = _similarItemsProviders
|
||||
.OfType<IRemoteSimilarItemsProvider>()
|
||||
.Where(p => p.Supports(itemType));
|
||||
var matchingProviders = new List<ISimilarItemsProvider>(localProviders);
|
||||
|
||||
var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name);
|
||||
if (typeOptions?.SimilarItemProviders?.Length > 0)
|
||||
{
|
||||
matchingProviders.AddRange(remoteProviders
|
||||
.Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order
|
||||
? order
|
||||
: typeOptions?.SimilarItemProviders;
|
||||
var orderedProviders = matchingProviders
|
||||
.OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name))
|
||||
.ToList();
|
||||
|
||||
var allResults = new List<(BaseItem Item, float Score)>();
|
||||
var excludeIds = new HashSet<Guid> { item.Id };
|
||||
var excludeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() };
|
||||
foreach (var (providerOrder, provider) in orderedProviders.Index())
|
||||
{
|
||||
if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (provider is ILocalSimilarItemsProvider localProvider)
|
||||
{
|
||||
var query = new SimilarItemsQuery
|
||||
{
|
||||
User = user,
|
||||
Limit = requestedLimit - allResults.Count,
|
||||
DtoOptions = dtoOptions,
|
||||
ExcludeItemIds = [.. excludeIds],
|
||||
ExcludeArtistIds = excludeArtistIds
|
||||
};
|
||||
|
||||
var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var (position, resultItem) in items.Index())
|
||||
{
|
||||
var isNewId = excludeIds.Add(resultItem.Id);
|
||||
var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey());
|
||||
if (isNewId && isNewKey)
|
||||
{
|
||||
var score = CalculateScore(null, providerOrder, position);
|
||||
allResults.Add((resultItem, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (provider is IRemoteSimilarItemsProvider remoteProvider)
|
||||
{
|
||||
var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id);
|
||||
|
||||
var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false);
|
||||
if (cachedReferences is not null)
|
||||
{
|
||||
var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
|
||||
allResults.AddRange(resolvedItems);
|
||||
continue;
|
||||
}
|
||||
|
||||
var query = new SimilarItemsQuery
|
||||
{
|
||||
User = user,
|
||||
Limit = requestedLimit - allResults.Count,
|
||||
DtoOptions = dtoOptions,
|
||||
ExcludeItemIds = [.. excludeIds],
|
||||
ExcludeArtistIds = excludeArtistIds
|
||||
};
|
||||
|
||||
// Collect references in batches and resolve against local library.
|
||||
// Stop fetching once we have enough resolved local items.
|
||||
const int BatchSize = 20;
|
||||
const int MaxRemoteReferenceFetchLimit = 500;
|
||||
var remaining = requestedLimit - allResults.Count;
|
||||
var collectedReferences = new List<SimilarItemReference>();
|
||||
var pendingBatch = new List<SimilarItemReference>();
|
||||
|
||||
await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
collectedReferences.Add(reference);
|
||||
pendingBatch.Add(reference);
|
||||
|
||||
if (pendingBatch.Count >= BatchSize)
|
||||
{
|
||||
var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
|
||||
allResults.AddRange(resolvedItems);
|
||||
remaining -= resolvedItems.Count;
|
||||
pendingBatch.Clear();
|
||||
|
||||
if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve any remaining references in the last partial batch
|
||||
if (pendingBatch.Count > 0)
|
||||
{
|
||||
var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
|
||||
allResults.AddRange(resolvedItems);
|
||||
}
|
||||
|
||||
if (collectedReferences.Count > 0 && provider.CacheDuration is not null)
|
||||
{
|
||||
await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, item.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = allResults
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.Take(requestedLimit)
|
||||
.ToList();
|
||||
|
||||
return await FilterByLibraryAccessAsync(ordered, user, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BaseItem>> FilterByLibraryAccessAsync(
|
||||
IReadOnlyList<BaseItem> candidates,
|
||||
User? user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (candidates.Count == 0 || user is null)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var accessFilter = SimilarItemsAccessFilter.Build(user, _libraryManager);
|
||||
|
||||
// No accessible libraries means nothing to compare against, and an empty TopParentIds set
|
||||
// would disable the filter rather than reject everything.
|
||||
if (accessFilter.TopParentIds.Length == 0)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Guid[] candidateIds = [.. candidates.Select(c => c.Id)];
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.WhereOneOrMany(candidateIds, e => e.Id);
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (allowedCount == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var filtered = candidates.Where(c => allowedIds.Contains(c.Id)).ToList();
|
||||
_logger.LogDebug(
|
||||
"Dropped {Dropped} of {Total} similar-item candidates due to user access filtering",
|
||||
candidates.Count - filtered.Count,
|
||||
candidates.Count);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
|
||||
User? user,
|
||||
Guid parentId,
|
||||
int categoryLimit,
|
||||
int itemLimit,
|
||||
DtoOptions dtoOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dtoOptions);
|
||||
|
||||
var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Movie],
|
||||
OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)],
|
||||
Limit = 7,
|
||||
ParentId = parentId,
|
||||
Recursive = true,
|
||||
IsPlayed = true,
|
||||
EnableGroupByMetadataKey = true,
|
||||
DtoOptions = dtoOptions
|
||||
});
|
||||
|
||||
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||
{
|
||||
itemTypes.Add(BaseItemKind.Trailer);
|
||||
itemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||
}
|
||||
|
||||
var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = itemTypes.ToArray(),
|
||||
IsMovie = true,
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Descending)],
|
||||
Limit = 10,
|
||||
IsFavoriteOrLiked = true,
|
||||
ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
|
||||
EnableGroupByMetadataKey = true,
|
||||
ParentId = parentId,
|
||||
Recursive = true,
|
||||
DtoOptions = dtoOptions
|
||||
});
|
||||
|
||||
var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
|
||||
var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]);
|
||||
var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]);
|
||||
|
||||
// Cap baseline items to categoryLimit - the round-robin can't use more categories than that.
|
||||
var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit
|
||||
? recentlyPlayedMovies.Take(categoryLimit).ToList()
|
||||
: recentlyPlayedMovies;
|
||||
var likedBaseline = likedMovies.Count > categoryLimit
|
||||
? likedMovies.Take(categoryLimit).ToList()
|
||||
: likedMovies;
|
||||
|
||||
var batchQuery = new SimilarItemsQuery
|
||||
{
|
||||
User = user,
|
||||
Limit = itemLimit,
|
||||
DtoOptions = dtoOptions
|
||||
};
|
||||
|
||||
var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync(
|
||||
recentlyPlayedBaseline,
|
||||
RecommendationType.SimilarToRecentlyPlayed,
|
||||
batchQuery,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var similarToLiked = await GetSimilarItemsRecommendationsAsync(
|
||||
likedBaseline,
|
||||
RecommendationType.SimilarToLikedItem,
|
||||
batchQuery,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes);
|
||||
var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes);
|
||||
|
||||
// Use a single enumerator per list, listed twice so MoveNext advances it
|
||||
// twice per round-robin pass (giving these categories double weight).
|
||||
// IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once;
|
||||
// using var would box separately per list insertion, creating independent copies.
|
||||
IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
|
||||
IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
|
||||
|
||||
var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>>
|
||||
{
|
||||
similarToRecentlyPlayedEnum,
|
||||
similarToRecentlyPlayedEnum,
|
||||
similarToLikedEnum,
|
||||
similarToLikedEnum,
|
||||
hasDirectorFromRecentlyPlayed.GetEnumerator(),
|
||||
hasActorFromRecentlyPlayed.GetEnumerator()
|
||||
};
|
||||
|
||||
var categories = new List<SimilarItemsRecommendation>();
|
||||
while (categories.Count < categoryLimit)
|
||||
{
|
||||
var allEmpty = true;
|
||||
foreach (var category in categoryTypes)
|
||||
{
|
||||
if (category.MoveNext())
|
||||
{
|
||||
categories.Add(category.Current);
|
||||
allEmpty = false;
|
||||
|
||||
if (categories.Count >= categoryLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allEmpty)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [.. categories.OrderBy(i => i.RecommendationType)];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync(
|
||||
IReadOnlyList<BaseItem> baselineItems,
|
||||
RecommendationType recommendationType,
|
||||
SimilarItemsQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var batchProvider = _similarItemsProviders
|
||||
.OfType<IBatchLocalSimilarItemsProvider>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (batchProvider is null || baselineItems.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter once across every category rather than per baseline, so a batch provider costs one
|
||||
// access query no matter how many categories it produced.
|
||||
var allItems = batchResults.Values.SelectMany(items => items).DistinctBy(item => item.Id).ToList();
|
||||
var allowed = await FilterByLibraryAccessAsync(allItems, query.User, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
HashSet<Guid>? allowedIds = allowed.Count == allItems.Count
|
||||
? null
|
||||
: [.. allowed.Select(item => item.Id)];
|
||||
|
||||
var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
|
||||
foreach (var baseline in baselineItems)
|
||||
{
|
||||
if (!batchResults.TryGetValue(baseline.Id, out var similar) || similar.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowedIds is not null)
|
||||
{
|
||||
similar = similar.Where(item => allowedIds.Contains(item.Id)).ToList();
|
||||
if (similar.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
recommendations.Add(new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = baseline.Name,
|
||||
CategoryId = baseline.Id,
|
||||
RecommendationType = recommendationType,
|
||||
Items = similar
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations(
|
||||
User? user,
|
||||
IReadOnlyList<string> names,
|
||||
int itemLimit,
|
||||
DtoOptions dtoOptions,
|
||||
RecommendationType type,
|
||||
IReadOnlyList<BaseItemKind> itemTypes)
|
||||
{
|
||||
var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
|
||||
? [PersonType.Director]
|
||||
: Array.Empty<string>();
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
Person = name,
|
||||
Limit = itemLimit + 2,
|
||||
PersonTypes = personTypes,
|
||||
IncludeItemTypes = itemTypes.ToArray(),
|
||||
IsMovie = true,
|
||||
IsPlayed = false,
|
||||
EnableGroupByMetadataKey = true,
|
||||
DtoOptions = dtoOptions
|
||||
})
|
||||
.DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
|
||||
.Take(itemLimit)
|
||||
.ToList();
|
||||
|
||||
if (items.Count > 0)
|
||||
{
|
||||
yield return new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = name,
|
||||
CategoryId = name.GetMD5(),
|
||||
RecommendationType = type,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes)
|
||||
{
|
||||
var itemIds = items.Select(i => i.Id).ToArray();
|
||||
return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes)
|
||||
.Values
|
||||
.SelectMany(names => names)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
|
||||
IReadOnlyList<SimilarItemReference> references,
|
||||
int providerOrder,
|
||||
User? user,
|
||||
DtoOptions dtoOptions,
|
||||
BaseItemKind itemKind,
|
||||
HashSet<Guid> excludeIds,
|
||||
HashSet<string> excludeKeys)
|
||||
{
|
||||
if (references.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase);
|
||||
var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance);
|
||||
|
||||
foreach (var (position, match) in references.Index())
|
||||
{
|
||||
var lookupKey = (match.ProviderName, match.ProviderId);
|
||||
if (!providerLookup.TryGetValue(lookupKey, out var existing))
|
||||
{
|
||||
providerLookup[lookupKey] = (match.Score, position);
|
||||
}
|
||||
else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position))
|
||||
{
|
||||
providerLookup[lookupKey] = (match.Score, position);
|
||||
}
|
||||
}
|
||||
|
||||
var allProviderIds = providerLookup
|
||||
.GroupBy(kvp => kvp.Key.ProviderName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray());
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
HasAnyProviderIds = allProviderIds,
|
||||
IncludeItemTypes = [itemKind],
|
||||
DtoOptions = dtoOptions
|
||||
};
|
||||
|
||||
var items = _libraryManager.GetItemList(query);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (excludeIds.Contains(item.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var presentationKey = item.GetPresentationUniqueKey();
|
||||
if (excludeKeys.Contains(presentationKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var providerName in allProviderIds.Keys)
|
||||
{
|
||||
if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo))
|
||||
{
|
||||
var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
|
||||
if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score)
|
||||
{
|
||||
resolvedByKey[presentationKey] = (item, score);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (key, entry) in resolvedByKey)
|
||||
{
|
||||
excludeIds.Add(entry.Item.Id);
|
||||
excludeKeys.Add(key);
|
||||
}
|
||||
|
||||
return [.. resolvedByKey.Values];
|
||||
}
|
||||
|
||||
private static float CalculateScore(float? matchScore, int providerOrder, int position)
|
||||
{
|
||||
// Use provider-supplied score if available, otherwise derive from position
|
||||
var baseScore = matchScore ?? (1.0f - (position * 0.02f));
|
||||
|
||||
// Apply small boost based on provider order (higher priority providers get small bonus)
|
||||
var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f;
|
||||
|
||||
return Math.Clamp(baseScore + priorityBoost, 0f, 1f);
|
||||
}
|
||||
|
||||
private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName)
|
||||
{
|
||||
if (orderConfig is null || orderConfig.Length == 0)
|
||||
{
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnoreCase));
|
||||
return index >= 0 ? index : int.MaxValue;
|
||||
}
|
||||
|
||||
private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId)
|
||||
{
|
||||
var dataPath = Path.Combine(
|
||||
_appPaths.CachePath,
|
||||
$"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}");
|
||||
return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json");
|
||||
}
|
||||
|
||||
private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var fileInfo = _fileSystem.GetFileSystemInfo(cachePath);
|
||||
if (!fileInfo.Exists || fileInfo.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var stream = File.OpenRead(cachePath);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
|
||||
if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt)
|
||||
{
|
||||
return cache.References;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(cachePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var cache = new SimilarItemsCache
|
||||
{
|
||||
References = references,
|
||||
ExpiresAt = DateTime.UtcNow.Add(cacheDuration)
|
||||
};
|
||||
|
||||
var stream = File.Create(cachePath);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SimilarItemsCache
|
||||
{
|
||||
public List<SimilarItemReference>? References { get; set; }
|
||||
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)>
|
||||
{
|
||||
public static readonly StringTupleComparer Instance = new();
|
||||
|
||||
public bool Equals((string Key, string Value) x, (string Key, string Value) y)
|
||||
=> string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public int GetHashCode((string Key, string Value) obj)
|
||||
=> HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key),
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value));
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ public class SplashscreenPostScanTask : ILibraryPostScanTask
|
||||
ImageTypes = [imageType],
|
||||
Limit = 30,
|
||||
// TODO max parental rating configurable
|
||||
MaxParentalRating = new(13, null),
|
||||
MaxParentalRating = new(10, null),
|
||||
OrderBy =
|
||||
[
|
||||
(ItemSortBy.Random, SortOrder.Ascending)
|
||||
|
||||
@@ -177,167 +177,53 @@ namespace Emby.Server.Implementations.Library
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
||||
private UserItemData? GetUserData(User user, Guid itemId, List<string> keys)
|
||||
{
|
||||
var result = new Dictionary<Guid, UserItemData>(items.Count);
|
||||
var itemsNeedingQuery = new List<(BaseItem Item, List<string> Keys)>();
|
||||
var cacheKey = GetCacheKey(user.InternalId, itemId);
|
||||
|
||||
foreach (var item in items)
|
||||
if (_cache.TryGet(cacheKey, out var data))
|
||||
{
|
||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||
if (_cache.TryGet(cacheKey, out var cachedData))
|
||||
{
|
||||
result[item.Id] = cachedData;
|
||||
}
|
||||
else
|
||||
{
|
||||
var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
||||
var userData = userDataRow is not null ? Map(userDataRow) : null;
|
||||
if (userData is not null)
|
||||
{
|
||||
result[item.Id] = userData;
|
||||
_cache.AddOrUpdate(cacheKey, userData);
|
||||
}
|
||||
else
|
||||
{
|
||||
var keys = item.GetUserDataKeys();
|
||||
itemsNeedingQuery.Add((item, keys));
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
if (itemsNeedingQuery.Count == 0)
|
||||
data = GetUserDataInternal(user.Id, itemId, keys);
|
||||
|
||||
if (data is null)
|
||||
{
|
||||
return result;
|
||||
return new UserItemData()
|
||||
{
|
||||
Key = keys[0],
|
||||
};
|
||||
}
|
||||
|
||||
return _cache.GetOrAdd(cacheKey, _ => data);
|
||||
}
|
||||
|
||||
private UserItemData? GetUserDataInternal(Guid userId, Guid itemId, List<string> keys)
|
||||
{
|
||||
if (keys.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build a single query for all missing items. Fetch rows by item alone so rows kept
|
||||
// under keys from older metadata resolve the same way as the in-memory path.
|
||||
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
|
||||
using var context = _repository.CreateDbContext();
|
||||
var userDataArray = context.UserData
|
||||
.AsNoTracking()
|
||||
.Where(e => e.UserId.Equals(user.Id))
|
||||
.WhereOneOrMany(allItemIds, e => e.ItemId)
|
||||
.ToArray();
|
||||
var userData = context.UserData.AsNoTracking().Where(e => e.ItemId == itemId && keys.Contains(e.CustomDataKey) && e.UserId.Equals(userId)).ToArray();
|
||||
|
||||
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
|
||||
foreach (var (item, keys) in itemsNeedingQuery)
|
||||
if (userData.Length > 0)
|
||||
{
|
||||
UserItemData userData;
|
||||
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
|
||||
var directDataReference = userData.FirstOrDefault(e => e.CustomDataKey == itemId.ToString("N"));
|
||||
if (directDataReference is not null)
|
||||
{
|
||||
userData = Map(ResolveUserDataRow(item, itemUserData)!);
|
||||
}
|
||||
else
|
||||
{
|
||||
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
|
||||
return Map(directDataReference);
|
||||
}
|
||||
|
||||
result[item.Id] = userData;
|
||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||
_cache.AddOrUpdate(cacheKey, userData);
|
||||
return Map(userData.First());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public VersionResumeData? GetResumeUserData(User user, BaseItem item)
|
||||
{
|
||||
return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
var result = new Dictionary<Guid, VersionResumeData>();
|
||||
|
||||
// Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data.
|
||||
// Linked alternates are already known in memory; only the local-alternate existence check
|
||||
// would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions),
|
||||
// so collect those ids and resolve them all in a single query below.
|
||||
List<Video>? candidates = null;
|
||||
List<Guid>? localProbeIds = null;
|
||||
foreach (var item in items)
|
||||
return new UserItemData
|
||||
{
|
||||
if (item is not Video video || video.PrimaryVersionId.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(candidates ??= []).Add(video);
|
||||
|
||||
if (video.LinkedAlternateVersions.Length == 0)
|
||||
{
|
||||
(localProbeIds ??= []).Add(video.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
HashSet<Guid>? withLocalAlternates = null;
|
||||
if (localProbeIds is not null)
|
||||
{
|
||||
using var dbContext = _repository.CreateDbContext();
|
||||
withLocalAlternates = dbContext.LinkedChildren
|
||||
.Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion)
|
||||
.WhereOneOrMany(localProbeIds, lc => lc.ParentId)
|
||||
.Select(lc => lc.ParentId)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null;
|
||||
List<BaseItem>? allVersions = null;
|
||||
|
||||
foreach (var video in candidates)
|
||||
{
|
||||
// Only items that actually have alternate versions aggregate over them.
|
||||
if (video.LinkedAlternateVersions.Length == 0
|
||||
&& (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var versions = video.GetAllVersions();
|
||||
if (versions.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(versionGroups ??= []).Add((video.Id, versions));
|
||||
(allVersions ??= []).AddRange(versions);
|
||||
}
|
||||
|
||||
if (versionGroups is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user);
|
||||
|
||||
foreach (var (primaryId, versions) in versionGroups)
|
||||
{
|
||||
// Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
|
||||
var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||
versions,
|
||||
version => userDataByVersion.GetValueOrDefault(version.Id),
|
||||
data => data.PlaybackPositionTicks > 0 || data.Played);
|
||||
|
||||
if (resumeVersion is not null)
|
||||
{
|
||||
result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
Key = keys.Last()!
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -350,41 +236,16 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public UserItemData? GetUserData(User user, BaseItem item)
|
||||
public UserItemData GetUserData(User user, BaseItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
||||
return row is not null ? Map(row) : new UserItemData()
|
||||
{
|
||||
Key = item.GetUserDataKeys()[0],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the row matching the item's current user data keys, in key order, so rows left behind
|
||||
/// under keys from older metadata don't take priority over the rows the write path updates.
|
||||
/// </summary>
|
||||
/// <param name="item">The item whose keys to match.</param>
|
||||
/// <param name="rows">The candidate user data rows for a single user.</param>
|
||||
/// <returns>The best matching row, or <c>null</c> when there are none.</returns>
|
||||
private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows)
|
||||
{
|
||||
var candidates = rows?.ToList();
|
||||
if (candidates is null || candidates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in item.GetUserDataKeys())
|
||||
{
|
||||
var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal));
|
||||
if (match is not null)
|
||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||
return _cache.GetOrAdd(
|
||||
cacheKey,
|
||||
(k, i) => i.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
||||
{
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
Key = i.GetUserDataKeys()[0],
|
||||
},
|
||||
item);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -403,10 +264,6 @@ namespace Emby.Server.Implementations.Library
|
||||
var dto = GetUserItemDataDto(userData, item.Id);
|
||||
|
||||
item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
|
||||
|
||||
// For an item with alternate versions, surface the most recently played version's resume point.
|
||||
GetResumeUserData(user, item)?.ApplyTo(dto);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -511,41 +368,5 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
return playedToCompletion;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetPlaybackStreamSelections(User user, BaseItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
using var dbContext = _repository.CreateDbContext();
|
||||
var rows = dbContext.UserData
|
||||
.Where(e => e.ItemId == item.Id && e.UserId == user.Id
|
||||
&& (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null))
|
||||
.ToList();
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
row.AudioStreamIndex = null;
|
||||
row.SubtitleStreamIndex = null;
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||
if (_cache.TryGet(cacheKey, out var cached))
|
||||
{
|
||||
cached.AudioStreamIndex = null;
|
||||
cached.SubtitleStreamIndex = null;
|
||||
_cache.AddOrUpdate(cacheKey, cached);
|
||||
}
|
||||
|
||||
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ namespace Emby.Server.Implementations.Library
|
||||
var collectionFolder = folder as ICollectionFolder;
|
||||
var folderViewType = collectionFolder?.CollectionType;
|
||||
|
||||
// Playlist and BoxSet libraries require special handling because the folder only references linked items
|
||||
if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
|
||||
// Playlist library requires special handling because the folder only references user playlists
|
||||
if (folderViewType == CollectionType.playlists)
|
||||
{
|
||||
var items = folder.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
@@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
if (_config.Configuration.EnableFolderView)
|
||||
{
|
||||
var name = _localizationManager.GetServerLocalizedString("Folders");
|
||||
var name = _localizationManager.GetLocalizedString("Folders");
|
||||
list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty));
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ namespace Emby.Server.Implementations.Library
|
||||
list = list.Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes).Contains(i.Id)).ToList();
|
||||
}
|
||||
|
||||
var sorted = _libraryManager.Sort(list, user, [ItemSortBy.SortName], SortOrder.Ascending).ToList();
|
||||
var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList();
|
||||
var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews);
|
||||
|
||||
return list
|
||||
@@ -168,7 +168,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName)
|
||||
{
|
||||
var name = _localizationManager.GetServerLocalizedString(localizationKey);
|
||||
var name = _localizationManager.GetLocalizedString(localizationKey);
|
||||
|
||||
return GetUserSubViewWithName(name, parentId, type, sortName);
|
||||
}
|
||||
@@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library
|
||||
return GetUserView((Folder)parents[0], viewType, string.Empty);
|
||||
}
|
||||
|
||||
var name = _localizationManager.GetServerLocalizedString(localizationKey);
|
||||
var name = _localizationManager.GetLocalizedString(localizationKey);
|
||||
return _libraryManager.GetNamedView(user, name, viewType, sortName);
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ namespace Emby.Server.Implementations.Library
|
||||
var libraryItems = GetItemsForLatestItems(request.User, request, options);
|
||||
|
||||
var list = new List<Tuple<BaseItem, List<BaseItem>>>();
|
||||
var containerIndexMap = new Dictionary<Guid, int>();
|
||||
|
||||
foreach (var item in libraryItems)
|
||||
{
|
||||
// Only grab the index container for media
|
||||
@@ -213,16 +213,20 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
if (container is null)
|
||||
{
|
||||
list.Add(new Tuple<BaseItem, List<BaseItem>>(null!, new List<BaseItem> { item }));
|
||||
}
|
||||
else if (containerIndexMap.TryGetValue(container.Id, out var existingIndex))
|
||||
{
|
||||
list[existingIndex].Item2.Add(item);
|
||||
list.Add(new Tuple<BaseItem, List<BaseItem>>(null, new List<BaseItem> { item }));
|
||||
}
|
||||
else
|
||||
{
|
||||
containerIndexMap[container.Id] = list.Count;
|
||||
list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item }));
|
||||
var current = list.FirstOrDefault(i => i.Item1 is not null && i.Item1.Id.Equals(container.Id));
|
||||
|
||||
if (current is not null)
|
||||
{
|
||||
current.Item2.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item }));
|
||||
}
|
||||
}
|
||||
|
||||
if (list.Count >= request.Limit)
|
||||
@@ -251,7 +255,7 @@ namespace Emby.Server.Implementations.Library
|
||||
return _channelManager.GetLatestChannelItemsInternal(
|
||||
new InternalItemsQuery(user)
|
||||
{
|
||||
ChannelIds = [parentId],
|
||||
ChannelIds = new[] { parentId },
|
||||
IsPlayed = request.IsPlayed,
|
||||
StartIndex = request.StartIndex,
|
||||
Limit = request.Limit,
|
||||
@@ -297,11 +301,11 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies))
|
||||
{
|
||||
includeItemTypes = [BaseItemKind.Movie];
|
||||
includeItemTypes = new[] { BaseItemKind.Movie };
|
||||
}
|
||||
else if (hasCollectionType.All(i => i.CollectionType == CollectionType.tvshows))
|
||||
{
|
||||
includeItemTypes = [BaseItemKind.Episode];
|
||||
includeItemTypes = new[] { BaseItemKind.Episode };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,29 +344,29 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Length == 0
|
||||
?
|
||||
[
|
||||
? new[]
|
||||
{
|
||||
BaseItemKind.Person,
|
||||
BaseItemKind.Studio,
|
||||
BaseItemKind.Year,
|
||||
BaseItemKind.MusicGenre,
|
||||
BaseItemKind.Genre
|
||||
]
|
||||
}
|
||||
: Array.Empty<BaseItemKind>();
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
OrderBy =
|
||||
[
|
||||
OrderBy = new[]
|
||||
{
|
||||
(ItemSortBy.DateCreated, SortOrder.Descending),
|
||||
(ItemSortBy.SortName, SortOrder.Descending),
|
||||
(ItemSortBy.ProductionYear, SortOrder.Descending)
|
||||
],
|
||||
},
|
||||
IsFolder = includeItemTypes.Length == 0 ? false : null,
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
IsVirtualItem = false,
|
||||
Limit = limit * 2,
|
||||
Limit = limit * 5,
|
||||
IsPlayed = isPlayed,
|
||||
DtoOptions = options,
|
||||
MediaTypes = mediaTypes
|
||||
@@ -390,18 +394,6 @@ namespace Emby.Server.Implementations.Library
|
||||
query.Limit = limit;
|
||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.music);
|
||||
}
|
||||
|
||||
if (collectionType == CollectionType.movies)
|
||||
{
|
||||
query.Limit = limit;
|
||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies);
|
||||
}
|
||||
|
||||
if (collectionType is null)
|
||||
{
|
||||
query.Limit = limit;
|
||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.unknown);
|
||||
}
|
||||
}
|
||||
|
||||
return _libraryManager.GetItemList(query, parents);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -51,55 +50,25 @@ public class ArtistsValidator
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var names = _itemRepo.GetAllArtistNames();
|
||||
var existingArtistIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.MusicArtist]
|
||||
}).ToHashSet();
|
||||
|
||||
var existingArtists = _libraryManager.GetArtists(names);
|
||||
|
||||
var numComplete = 0;
|
||||
var count = names.Count;
|
||||
var refreshed = 0;
|
||||
|
||||
var liveIds = new HashSet<Guid>();
|
||||
var unresolved = 0;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
try
|
||||
{
|
||||
MusicArtist? item = null;
|
||||
if (existingArtists.TryGetValue(name, out var artists) && artists.Length > 0)
|
||||
{
|
||||
item = artists.OrderBy(i => i.IsAccessedByName ? 1 : 0).First();
|
||||
}
|
||||
var item = _libraryManager.GetArtist(name);
|
||||
|
||||
// Fall back to GetArtist if not found (creates new item if needed)
|
||||
item ??= _libraryManager.GetArtist(name);
|
||||
|
||||
// A name with no item is nothing to refresh, and nothing to keep alive either.
|
||||
if (item is not null)
|
||||
{
|
||||
liveIds.Add(item.Id);
|
||||
|
||||
var isNew = !existingArtistIds.Contains(item.Id);
|
||||
var neverRefreshed = item.DateLastRefreshed == default;
|
||||
|
||||
if (isNew || neverRefreshed)
|
||||
{
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
}
|
||||
}
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Don't clutter the log
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
unresolved++;
|
||||
_logger.LogError(ex, "Error refreshing {ArtistName}", name);
|
||||
}
|
||||
|
||||
@@ -111,36 +80,30 @@ public class ArtistsValidator
|
||||
progress.Report(percent);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count);
|
||||
|
||||
// Every name that threw is a name whose artist is missing from the live set, and deleting against
|
||||
// a live set with holes in it deletes artists the library still refers to. Leave the sweep to a
|
||||
// run that got a clean read of them.
|
||||
if (unresolved > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Not removing dead artists: {Count} of {TotalCount} names could not be resolved this run",
|
||||
unresolved,
|
||||
count);
|
||||
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||
IsDeadArtist = true,
|
||||
IsLocked = false
|
||||
}).OfType<MusicArtist>()
|
||||
.Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id))
|
||||
.ToList();
|
||||
}).Cast<MusicArtist>().ToList();
|
||||
|
||||
foreach (var item in deadEntities)
|
||||
{
|
||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||
}
|
||||
if (!item.IsAccessedByName)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
|
||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||
|
||||
_libraryManager.DeleteItem(
|
||||
item,
|
||||
new DeleteOptions
|
||||
{
|
||||
DeleteFileLocation = false
|
||||
},
|
||||
false);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ public class CollectionPostScanTask : ILibraryPostScanTask
|
||||
|
||||
foreach (var m in movies)
|
||||
{
|
||||
if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName) && !movie.PrimaryVersionId.HasValue)
|
||||
if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName))
|
||||
{
|
||||
if (collectionNameMoviesMap.TryGetValue(movie.CollectionName, out var movieList))
|
||||
{
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures top-level library folders have a primary poster after scans.
|
||||
/// Poster extraction is attempted before library scanning. When a library is
|
||||
/// empty at that point, no poster can be extracted. This post-scan task reruns
|
||||
/// metadata extraction for top-level folders that are still missing images.
|
||||
/// </summary>
|
||||
public class CollectionPosterVerifyPostScanTask : ILibraryPostScanTask
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<CollectionPosterVerifyPostScanTask> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CollectionPosterVerifyPostScanTask" /> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public CollectionPosterVerifyPostScanTask(
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<CollectionPosterVerifyPostScanTask> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the specified progress.
|
||||
/// </summary>
|
||||
/// <param name="progress">The progress.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var libraries = _libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().ToList();
|
||||
var totalLibraries = libraries.Count;
|
||||
var processedLibraries = 0;
|
||||
|
||||
foreach (var library in libraries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!library.HasImage(ImageType.Primary))
|
||||
{
|
||||
_logger.LogDebug("Library {LibraryName} is missing a primary image. Refreshing metadata.", library.Name);
|
||||
await library.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
processedLibraries++;
|
||||
progress.Report((double)processedLibraries / totalLibraries * 100);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
@@ -49,40 +48,17 @@ public class GenresValidator
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var names = _itemRepo.GetGenreNames();
|
||||
var existingGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Genre]
|
||||
}).ToHashSet();
|
||||
|
||||
var existingGenres = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Genre]
|
||||
}).Cast<Genre>()
|
||||
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var numComplete = 0;
|
||||
var count = names.Count;
|
||||
var refreshed = 0;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
try
|
||||
{
|
||||
Genre? item = null;
|
||||
if (existingGenres.TryGetValue(name, out var existingGenre))
|
||||
{
|
||||
item = existingGenre;
|
||||
}
|
||||
var item = _libraryManager.GetGenre(name);
|
||||
|
||||
// Fall back to GetGenre if not found (creates new item if needed)
|
||||
item ??= _libraryManager.GetGenre(name);
|
||||
|
||||
if (!existingGenreIds.Contains(item.Id))
|
||||
{
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
}
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -102,8 +78,6 @@ public class GenresValidator
|
||||
progress.Report(percent);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new genres out of {TotalCount} total", refreshed, count);
|
||||
|
||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre],
|
||||
@@ -114,9 +88,15 @@ public class GenresValidator
|
||||
foreach (var item in deadEntities)
|
||||
{
|
||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||
}
|
||||
|
||||
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
|
||||
_libraryManager.DeleteItem(
|
||||
item,
|
||||
new DeleteOptions
|
||||
{
|
||||
DeleteFileLocation = false
|
||||
},
|
||||
false);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -48,25 +45,17 @@ public class MusicGenresValidator
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var names = _itemRepo.GetMusicGenreNames();
|
||||
var existingMusicGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.MusicGenre]
|
||||
}).ToHashSet();
|
||||
|
||||
var numComplete = 0;
|
||||
var count = names.Count;
|
||||
var refreshed = 0;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
try
|
||||
{
|
||||
var item = _libraryManager.GetMusicGenre(name);
|
||||
if (!existingMusicGenreIds.Contains(item.Id))
|
||||
{
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
}
|
||||
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -86,8 +75,6 @@ public class MusicGenresValidator
|
||||
progress.Report(percent);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new music genres out of {TotalCount} total", refreshed, count);
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Validators;
|
||||
@@ -17,143 +17,104 @@ namespace Emby.Server.Implementations.Library.Validators;
|
||||
public class PeopleValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// The library manager.
|
||||
/// The _library manager.
|
||||
/// </summary>
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// The logger.
|
||||
/// The _logger.
|
||||
/// </summary>
|
||||
private readonly ILogger<PeopleValidator> _logger;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PeopleValidator" /> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger)
|
||||
/// <param name="fileSystem">The file system.</param>
|
||||
public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_fileSystem = fileSystem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the people.
|
||||
/// </summary>
|
||||
/// <param name="progress">The progress.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <param name="progress">The progress.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
|
||||
{
|
||||
// Before the refresh below walks them: a credit no item maps to any more stands for nothing,
|
||||
// and while it is there the person it names cannot reach the dead-person sweep either.
|
||||
var numOrphaned = _libraryManager.DeleteOrphanedCredits();
|
||||
if (numOrphaned > 0)
|
||||
{
|
||||
_logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned);
|
||||
}
|
||||
|
||||
var names = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
|
||||
var existingPersonIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Person]
|
||||
}).ToHashSet();
|
||||
|
||||
var (newNames, deadIds) = PartitionCreditsByPersonId(names, _libraryManager.GetPersonId, existingPersonIds);
|
||||
var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
|
||||
|
||||
var numComplete = 0;
|
||||
var count = names.Count;
|
||||
var refreshed = 0;
|
||||
|
||||
foreach (var name in names)
|
||||
var numPeople = people.Count;
|
||||
|
||||
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||
|
||||
_logger.LogDebug("Will refresh {Amount} people", numPeople);
|
||||
|
||||
foreach (var person in people)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var item = _libraryManager.GetOrCreatePerson(name);
|
||||
var isNew = !existingPersonIds.Contains(item.Id);
|
||||
var neverRefreshed = item.DateLastRefreshed == default;
|
||||
|
||||
if (isNew || neverRefreshed)
|
||||
var item = _libraryManager.GetPerson(person);
|
||||
if (item is null)
|
||||
{
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
_logger.LogWarning("Failed to get person: {Name}", person);
|
||||
continue;
|
||||
}
|
||||
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
|
||||
MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
|
||||
};
|
||||
|
||||
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Don't clutter the log
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing {PersonName}", name);
|
||||
_logger.LogError(ex, "Error validating IBN entry {Person}", person);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
numComplete++;
|
||||
double percent = numComplete;
|
||||
percent /= count;
|
||||
percent *= 100;
|
||||
percent /= numPeople;
|
||||
|
||||
progress.Report(percent);
|
||||
subProgress.Report(100 * percent);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet",
|
||||
refreshed,
|
||||
count,
|
||||
newNames.Count);
|
||||
|
||||
// A person somebody locked is theirs, not ours, however little the library still credits them.
|
||||
var deadEntities = deadIds
|
||||
.Select(_libraryManager.GetItemById)
|
||||
.OfType<Person>()
|
||||
.Where(item => !item.IsLocked)
|
||||
.ToList();
|
||||
|
||||
foreach (var item in deadEntities)
|
||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||
}
|
||||
IncludeItemTypes = [BaseItemKind.Person],
|
||||
IsDeadPerson = true,
|
||||
IsLocked = false
|
||||
});
|
||||
|
||||
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
|
||||
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||
|
||||
var i = 0;
|
||||
foreach (var item in deadEntities.Chunk(500))
|
||||
{
|
||||
_libraryManager.DeleteItemsUnsafeFast(item);
|
||||
subProgress.Report(100f / deadEntities.Count * (i++ * 100));
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits the person items into the ones a credit still calls for and the ones nothing does.
|
||||
/// </summary>
|
||||
/// <param name="creditNames">Every name credited on an item, from the people table.</param>
|
||||
/// <param name="getPersonId">Maps a credit name to the id its person item has.</param>
|
||||
/// <param name="existingPersonIds">The ids of the person items that exist.</param>
|
||||
/// <returns>The credits needing an item, and the ids of the items nothing credits.</returns>
|
||||
internal static (List<string> NewNames, List<Guid> DeadIds) PartitionCreditsByPersonId(
|
||||
IReadOnlyList<string> creditNames,
|
||||
Func<string, Guid> getPersonId,
|
||||
IReadOnlySet<Guid> existingPersonIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(creditNames);
|
||||
ArgumentNullException.ThrowIfNull(getPersonId);
|
||||
ArgumentNullException.ThrowIfNull(existingPersonIds);
|
||||
|
||||
var newNames = new List<string>();
|
||||
var liveIds = new HashSet<Guid>();
|
||||
|
||||
foreach (var name in creditNames)
|
||||
{
|
||||
var personId = getPersonId(name);
|
||||
|
||||
// Distinct credit names can normalize onto one id; only the first of them needs an item.
|
||||
if (liveIds.Add(personId) && !existingPersonIds.Contains(personId))
|
||||
{
|
||||
newNames.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
var deadIds = existingPersonIds.Where(id => !liveIds.Contains(id)).ToList();
|
||||
|
||||
return (newNames, deadIds);
|
||||
_logger.LogInformation("People validation complete");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
@@ -50,40 +49,17 @@ public class StudiosValidator
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var names = _itemRepo.GetStudioNames();
|
||||
var existingStudioIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Studio]
|
||||
}).ToHashSet();
|
||||
|
||||
var existingStudios = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Studio]
|
||||
}).Cast<Studio>()
|
||||
.GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var numComplete = 0;
|
||||
var count = names.Count;
|
||||
var refreshed = 0;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
try
|
||||
{
|
||||
Studio? item = null;
|
||||
if (existingStudios.TryGetValue(name, out var existingStudio))
|
||||
{
|
||||
item = existingStudio;
|
||||
}
|
||||
var item = _libraryManager.GetStudio(name);
|
||||
|
||||
// Fall back to GetStudio if not found (creates new item if needed)
|
||||
item ??= _libraryManager.GetStudio(name);
|
||||
|
||||
if (!existingStudioIds.Contains(item.Id))
|
||||
{
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
}
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -103,8 +79,6 @@ public class StudiosValidator
|
||||
progress.Report(percent);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new studios out of {TotalCount} total", refreshed, count);
|
||||
|
||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Studio],
|
||||
@@ -115,9 +89,15 @@ public class StudiosValidator
|
||||
foreach (var item in deadEntities)
|
||||
{
|
||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||
}
|
||||
|
||||
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
|
||||
_libraryManager.DeleteItem(
|
||||
item,
|
||||
new DeleteOptions
|
||||
{
|
||||
DeleteFileLocation = false
|
||||
},
|
||||
false);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"AppDeviceValues": "Апп: {0}, Априбор: {1}"
|
||||
"Albums": "аальбомқәа"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"Artists": "Kunstenare",
|
||||
"Channels": "Kanale",
|
||||
"Folders": "Lêergidse",
|
||||
"Favorites": "Gunstelinge",
|
||||
"HeaderFavoriteShows": "Gunsteling Vertonings",
|
||||
"ValueSpecialEpisodeName": "Spesiale - {0}",
|
||||
"HeaderAlbumArtists": "Album kunstenaars",
|
||||
"Books": "Boeke",
|
||||
"HeaderNextUp": "Volgende",
|
||||
"Movies": "Flieks",
|
||||
@@ -10,13 +13,24 @@
|
||||
"HeaderContinueWatching": "Hou aan kyk",
|
||||
"HeaderFavoriteEpisodes": "Gunsteling Episodes",
|
||||
"Photos": "Foto's",
|
||||
"Playlists": "Snitlyste",
|
||||
"HeaderFavoriteArtists": "Gunsteling Kunstenaars",
|
||||
"HeaderFavoriteAlbums": "Gunsteling Albums",
|
||||
"Sync": "Sinkroniseer",
|
||||
"HeaderFavoriteSongs": "Gunsteling Liedjies",
|
||||
"Songs": "Liedjies",
|
||||
"DeviceOnlineWithName": "{0} is aanlyn",
|
||||
"DeviceOfflineWithName": "{0} is ontkoppel",
|
||||
"Collections": "Versamelings",
|
||||
"Inherit": "Ontvang",
|
||||
"HeaderLiveTV": "Lewendige TV",
|
||||
"Application": "Program",
|
||||
"AppDeviceValues": "App: {0}, Toestel: {1}",
|
||||
"VersionNumber": "Weergawe {0}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} is by jou media biblioteek bygevoeg",
|
||||
"UserStoppedPlayingItemWithValues": "{0} het klaar {1} op {2} gespeel",
|
||||
"UserStartedPlayingItemWithValues": "{0} is besig om {1} op {2} te speel",
|
||||
"UserPolicyUpdatedWithName": "Gebruiker beleid is verander vir {0}",
|
||||
"UserPasswordChangedWithName": "Gebruiker {0} se wagwoord is verander",
|
||||
"UserOnlineFromDevice": "{0} is aanlyn van {1}",
|
||||
"UserOfflineFromDevice": "{0} is ontkoppel van {1}",
|
||||
@@ -24,13 +38,19 @@
|
||||
"UserDownloadingItemWithValues": "{0} is besig om {1} af te laai",
|
||||
"UserDeletedWithName": "Gebruiker {0} is verwyder",
|
||||
"UserCreatedWithName": "Gebruiker {0} is geskep",
|
||||
"User": "Gebruiker",
|
||||
"TvShows": "TV Programme",
|
||||
"System": "Stelsel",
|
||||
"SubtitleDownloadFailureFromForItem": "Ondertitels het misluk om af te laai van {0} vir {1}",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Bediener is besig om te laai. Probeer weer in 'n kort tyd.",
|
||||
"ServerNameNeedsToBeRestarted": "{0} moet herbegin word",
|
||||
"ScheduledTaskStartedWithName": "{0} het begin",
|
||||
"ScheduledTaskFailedWithName": "{0} het misluk",
|
||||
"ProviderValue": "Voorsiener: {0}",
|
||||
"PluginUpdatedWithName": "{0} was opgedateer",
|
||||
"PluginUninstalledWithName": "{0} was verwyder",
|
||||
"PluginInstalledWithName": "{0} is geïnstalleer",
|
||||
"Plugin": "Inprop module",
|
||||
"NotificationOptionVideoPlaybackStopped": "Video terugspeel het gestop",
|
||||
"NotificationOptionVideoPlayback": "Video terugspeel het begin",
|
||||
"NotificationOptionUserLockedOut": "Gebruiker uitgeslyt",
|
||||
@@ -54,14 +74,23 @@
|
||||
"MusicVideos": "Musiek Videos",
|
||||
"Music": "Musiek",
|
||||
"MixedContent": "Gemengde inhoud",
|
||||
"MessageServerConfigurationUpdated": "Bediener konfigurasie is opgedateer",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "Bediener konfigurasie seksie {0} is opgedateer",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin Bediener is opgedateer na {0}",
|
||||
"MessageApplicationUpdated": "Jellyfin Bediener is opgedateer",
|
||||
"Latest": "Nuutste",
|
||||
"LabelRunningTimeValue": "Werktyd: {0}",
|
||||
"LabelIpAddressValue": "IP adres: {0}",
|
||||
"ItemRemovedWithName": "{0} is uit versameling verwyder",
|
||||
"ItemAddedWithName": "{0} is by die versameling gevoeg",
|
||||
"HomeVideos": "Tuis Videos",
|
||||
"HeaderRecordingGroups": "Groep Opnames",
|
||||
"Genres": "Genres",
|
||||
"FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}",
|
||||
"ChapterNameValue": "Hoofstuk {0}",
|
||||
"CameraImageUploadedFrom": "'n Nuwe kamera foto is opgelaai vanaf {0}",
|
||||
"AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer",
|
||||
"Albums": "Albums",
|
||||
"TasksChannelsCategory": "Internet kanale",
|
||||
"TasksApplicationCategory": "aansoek",
|
||||
"TasksLibraryCategory": "biblioteek",
|
||||
@@ -99,12 +128,12 @@
|
||||
"TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling.",
|
||||
"TaskAudioNormalizationDescription": "Skandeer lêers vir oudio-normaliseringsdata.",
|
||||
"TaskAudioNormalization": "Odio Normalisering",
|
||||
"TaskCleanCollectionsAndPlaylists": "Maak versamelings en snitlyste skoon",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Verwyder items uit versamelings en snitlyste wat nie meer bestaan nie.",
|
||||
"TaskDownloadMissingLyrics": "Laai tekorte lirieke af",
|
||||
"TaskDownloadMissingLyricsDescription": "Laai lirieke af vir liedjies",
|
||||
"TaskExtractMediaSegments": "Media Segment Skandeer",
|
||||
"TaskExtractMediaSegmentsDescription": "Onttrek of verkry mediasegmente van MediaSegment-geaktiveerde inproppe.",
|
||||
"TaskMoveTrickplayImages": "Migreer Trickplay Beeldligging",
|
||||
"TaskMoveTrickplayImagesDescription": "Skuif ontstaande trickplay lêers volgens die biblioteekinstellings.",
|
||||
"CleanupUserDataTask": "Gebruikers data skoon maak taak",
|
||||
"CleanupUserDataTaskDescription": "Maak alle gebruikers data (kykstatus, gunstelingstatus, ens.) skoon van media wat nie meer vir ten minste 90 dae teenwoordig is nie."
|
||||
"TaskMoveTrickplayImagesDescription": "Skuif ontstaande trickplay lêers volgens die biblioteekinstellings."
|
||||
}
|
||||
|
||||
@@ -1,125 +1,142 @@
|
||||
{
|
||||
"AppDeviceValues": "التطبيق: {0}، الجهاز: {1}",
|
||||
"Albums": "ألبومات",
|
||||
"AppDeviceValues": "تطبيق: {0}, جهاز: {1}",
|
||||
"Application": "تطبيق",
|
||||
"Artists": "الفنانون",
|
||||
"AuthenticationSucceededWithUserName": "تمت مصادقة {0} بنجاح",
|
||||
"AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}",
|
||||
"Books": "الكتب",
|
||||
"CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}",
|
||||
"Channels": "القنوات",
|
||||
"ChapterNameValue": "الفصل {0}",
|
||||
"Collections": "المجموعات",
|
||||
"FailedLoginAttemptWithUserName": "محاولة تسجيل دخول فاشلة من {0}",
|
||||
"DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
|
||||
"DeviceOnlineWithName": "{0} متصل",
|
||||
"FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}",
|
||||
"Favorites": "المفضلة",
|
||||
"Folders": "المجلدات",
|
||||
"Genres": "الأنواع",
|
||||
"HeaderContinueWatching": "متابعة المشاهدة",
|
||||
"Genres": "التصنيفات",
|
||||
"HeaderAlbumArtists": "فناني الألبوم",
|
||||
"HeaderContinueWatching": "إستئناف المشاهدة",
|
||||
"HeaderFavoriteAlbums": "الألبومات المفضلة",
|
||||
"HeaderFavoriteArtists": "الفنانون المفضلون",
|
||||
"HeaderFavoriteEpisodes": "الحلقات المفضلة",
|
||||
"HeaderFavoriteShows": "المسلسلات المفضلة",
|
||||
"HeaderLiveTV": "البث التلفزيوني المباشر",
|
||||
"HeaderFavoriteSongs": "الأغاني المفضلة",
|
||||
"HeaderLiveTV": "التلفاز المباشر",
|
||||
"HeaderNextUp": "التالي",
|
||||
"HomeVideos": "فيديوهات منزلية",
|
||||
"Inherit": "وراثة",
|
||||
"LabelIpAddressValue": "عنوان IP: {0}",
|
||||
"HeaderRecordingGroups": "مجموعات التسجيل",
|
||||
"HomeVideos": "الفيديوهات الشخصية",
|
||||
"Inherit": "توريث",
|
||||
"ItemAddedWithName": "أُضيف {0} للمكتبة",
|
||||
"ItemRemovedWithName": "أُزيل {0} من المكتبة",
|
||||
"LabelIpAddressValue": "عنوان الآي بي: {0}",
|
||||
"LabelRunningTimeValue": "مدة التشغيل: {0}",
|
||||
"Latest": "الأحدث",
|
||||
"MessageApplicationUpdated": "حُدث خادم Jellyfin",
|
||||
"MessageApplicationUpdatedTo": "حُدث خادم Jellyfin إلى {0}",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "حُدثت إعدادات الخادم في قسم {0}",
|
||||
"MessageServerConfigurationUpdated": "حُدثت إعدادات الخادم",
|
||||
"MixedContent": "محتوى مختلط",
|
||||
"Movies": "الأفلام",
|
||||
"Music": "الموسيقى",
|
||||
"MusicVideos": "الفيديوهات الموسيقية",
|
||||
"NameInstallFailed": "فشل تثبيت {0}",
|
||||
"NameSeasonNumber": "الموسم {0}",
|
||||
"NameSeasonUnknown": "موسم غير معروف",
|
||||
"NewVersionIsAvailable": "يتوفر إصدار جديد من خادم Jellyfin للتنزيل.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "تحديث التطبيق متاح",
|
||||
"NotificationOptionApplicationUpdateInstalled": "تم تثبيت تحديث التطبيق",
|
||||
"NotificationOptionAudioPlayback": "بدأ تشغيل الصوت",
|
||||
"NotificationOptionAudioPlaybackStopped": "توقف تشغيل الصوت",
|
||||
"NotificationOptionCameraImageUploaded": "تم رفع صورة كاميرا",
|
||||
"NotificationOptionInstallationFailed": "فشل التثبيت",
|
||||
"NotificationOptionNewLibraryContent": "تمت إضافة محتوى جديد",
|
||||
"NotificationOptionPluginError": "خطأ في الملحق",
|
||||
"NotificationOptionPluginInstalled": "تم تثبيت الملحق",
|
||||
"NameSeasonUnknown": "الموسم غير معروف",
|
||||
"NewVersionIsAvailable": "نسخة جديدة من خادم Jellyfin متوفرة للتحميل.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "يوجد تحديث للتطبيق",
|
||||
"NotificationOptionApplicationUpdateInstalled": "نُصب تحديث التطبيق",
|
||||
"NotificationOptionAudioPlayback": "بدأ تشغيل المقطع الصوتي",
|
||||
"NotificationOptionAudioPlaybackStopped": "أُوقف تشغيل المقطع الصوتي",
|
||||
"NotificationOptionCameraImageUploaded": "رُفعت صورة الكاميرا",
|
||||
"NotificationOptionInstallationFailed": "فشل في التثبيت",
|
||||
"NotificationOptionNewLibraryContent": "أُضيف محتوى جديدا",
|
||||
"NotificationOptionPluginError": "فشل في الملحق",
|
||||
"NotificationOptionPluginInstalled": "ثُبتت الملحق",
|
||||
"NotificationOptionPluginUninstalled": "تمت إزالة الملحق",
|
||||
"NotificationOptionPluginUpdateInstalled": "تم تحديث الملحق",
|
||||
"NotificationOptionServerRestartRequired": "مطلوب إعادة تشغيل الخادم",
|
||||
"NotificationOptionTaskFailed": "فشل المهمة المجدولة",
|
||||
"NotificationOptionUserLockedOut": "تم قفل حساب المستخدم",
|
||||
"NotificationOptionPluginUpdateInstalled": "تم تثبيت تحديثات الملحق",
|
||||
"NotificationOptionServerRestartRequired": "يجب إعادة تشغيل الخادم",
|
||||
"NotificationOptionTaskFailed": "فشل في المهمة المجدولة",
|
||||
"NotificationOptionUserLockedOut": "تم إقفال حساب المستخدم",
|
||||
"NotificationOptionVideoPlayback": "بدأ تشغيل الفيديو",
|
||||
"NotificationOptionVideoPlaybackStopped": "توقف تشغيل الفيديو",
|
||||
"NotificationOptionVideoPlaybackStopped": "تم إيقاف تشغيل الفيديو",
|
||||
"Photos": "الصور",
|
||||
"Playlists": "قوائم التشغيل",
|
||||
"Plugin": "الملحق",
|
||||
"PluginInstalledWithName": "تم تثبيت {0}",
|
||||
"PluginUninstalledWithName": "تمت إزالة {0}",
|
||||
"PluginUpdatedWithName": "تم تحديث {0}",
|
||||
"ScheduledTaskFailedWithName": "فشلت {0}",
|
||||
"Shows": "المسلسلات",
|
||||
"StartupEmbyServerIsLoading": "يتم الآن تحميل خادم Jellyfin. يرجى المحاولة مرة أخرى بعد قليل.",
|
||||
"SubtitleDownloadFailureFromForItem": "فشل تنزيل الترجمات من {0} لـ {1}",
|
||||
"ProviderValue": "المزود: {0}",
|
||||
"ScheduledTaskFailedWithName": "فشلت العملية {0}",
|
||||
"ScheduledTaskStartedWithName": "تم بدء العملية {0}",
|
||||
"ServerNameNeedsToBeRestarted": "يحتاج {0} لإعادة التشغيل",
|
||||
"Shows": "العروض",
|
||||
"Songs": "الأغاني",
|
||||
"StartupEmbyServerIsLoading": "يتم تحميل خادم Jellyfin . الرجاء المحاولة بعد قليل.",
|
||||
"SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}",
|
||||
"SubtitleDownloadFailureFromForItem": "فشل تحميل الترجمات من {0} ل {1}",
|
||||
"Sync": "مزامنة",
|
||||
"System": "النظام",
|
||||
"TvShows": "البرامج التلفزيونية",
|
||||
"User": "المستخدم",
|
||||
"UserCreatedWithName": "تم إنشاء المستخدم {0}",
|
||||
"UserDeletedWithName": "تم حذف المستخدم {0}",
|
||||
"UserDownloadingItemWithValues": "{0} يقوم بتنزيل {1}",
|
||||
"UserLockedOutWithName": "تم قفل حساب المستخدم {0}",
|
||||
"UserOfflineFromDevice": "انقطع اتصال {0} من {1}",
|
||||
"UserOnlineFromDevice": "{0} متصل من {1}",
|
||||
"UserPasswordChangedWithName": "تم تغيير كلمة المرور للمستخدم {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} يقوم بتشغيل {1} على {2}",
|
||||
"UserStoppedPlayingItemWithValues": "أنهى {0} تشغيل {1} على {2}",
|
||||
"UserDownloadingItemWithValues": "يقوم {0} بتنزيل {1}",
|
||||
"UserLockedOutWithName": "تم منع المستخدم {0} من الدخول",
|
||||
"UserOfflineFromDevice": "تم قطع اتصال {0} من {1}",
|
||||
"UserOnlineFromDevice": "{0} متصل عبر {1}",
|
||||
"UserPasswordChangedWithName": "تم تغيير كلمة السر للمستخدم {0}",
|
||||
"UserPolicyUpdatedWithName": "تم تحديث سياسة المستخدم {0}",
|
||||
"UserStartedPlayingItemWithValues": "قام {0} ببدء تشغيل {1} على {2}",
|
||||
"UserStoppedPlayingItemWithValues": "قام {0} بإيقاف تشغيل {1} على {2}",
|
||||
"ValueHasBeenAddedToLibrary": "تمت اضافت {0} إلى مكتبة الوسائط",
|
||||
"ValueSpecialEpisodeName": "حلقة خاصه - {0}",
|
||||
"VersionNumber": "الإصدار {0}",
|
||||
"TaskCleanCacheDescription": "يحذف ملفات ذاكرة التخزين المؤقت التي لم يعد النظام بحاجة إليها.",
|
||||
"TaskCleanCache": "تنظيف مجلد ذاكرة التخزين المؤقت",
|
||||
"TaskCleanCacheDescription": "يحذف الملفات المؤقتة التي لم يعد النظام بحاجة إليها.",
|
||||
"TaskCleanCache": "حذف الملفات المؤقتة",
|
||||
"TasksChannelsCategory": "قنوات الإنترنت",
|
||||
"TasksLibraryCategory": "المكتبة",
|
||||
"TasksMaintenanceCategory": "الصيانة",
|
||||
"TaskRefreshLibraryDescription": "يفحص مكتبة المحتوى الخاصة بك بحثاً عن ملفات جديدة ويحدّث البيانات الوصفية.",
|
||||
"TaskRefreshLibrary": "فحص مكتبة المحتوى",
|
||||
"TaskRefreshChapterImagesDescription": "ينشئ صوراً مصغرة للفيديوهات التي تحتوي على فصول.",
|
||||
"TaskRefreshChapterImages": "استخراج صور الفصول",
|
||||
"TasksApplicationCategory": "التطبيق",
|
||||
"TaskDownloadMissingSubtitlesDescription": "يبحث في الإنترنت عن الترجمات المفقودة بناءً على إعدادات البيانات الوصفية.",
|
||||
"TaskDownloadMissingSubtitles": "تنزيل الترجمات المفقودة",
|
||||
"TaskRefreshChannelsDescription": "يحدّث معلومات قنوات الإنترنت.",
|
||||
"TaskRefreshChannels": "تحديث القنوات",
|
||||
"TaskCleanTranscodeDescription": "يحذف ملفات تحويل الترميز التي مر عليها أكثر من يوم واحد.",
|
||||
"TaskCleanTranscode": "تنظيف مجلد تحويل الترميز",
|
||||
"TaskUpdatePluginsDescription": "ينزّل ويثبّت التحديثات للملحقات المهيأة للتحديث التلقائي.",
|
||||
"TaskUpdatePlugins": "تحديث الملحقات",
|
||||
"TaskRefreshPeopleDescription": "يحدّث البيانات الوصفية للممثلين والمخرجين في مكتبة المحتوى الخاصة بك.",
|
||||
"TaskRefreshPeople": "تحديث الأشخاص",
|
||||
"TaskCleanLogsDescription": "يحذف ملفات السجل التي يزيد عمرها عن {0} أيام.",
|
||||
"TaskCleanLogs": "تنظيف مجلد السجلات",
|
||||
"TaskCleanActivityLogDescription": "يحذف إدخالات سجل النشاط الأقدم من العمر المحدد.",
|
||||
"TaskCleanActivityLog": "تنظيف سجل النشاط",
|
||||
"Default": "الافتراضي",
|
||||
"Undefined": "غير محدد",
|
||||
"Forced": "إجباري",
|
||||
"TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقلل المساحة الحرة. قد يؤدي تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تتضمن تعديلات على قاعدة البيانات إلى تحسين الأداء.",
|
||||
"TasksLibraryCategory": "مكتبة",
|
||||
"TasksMaintenanceCategory": "صيانة",
|
||||
"TaskRefreshLibraryDescription": "يفحص مكتبة الوسائط الخاصة بك باحثا عن ملفات جديدة، ومن ثم يُحدث البيانات الوصفية.",
|
||||
"TaskRefreshLibrary": "افحص مكتبة الوسائط",
|
||||
"TaskRefreshChapterImagesDescription": "يُنشئ صور مصغرة لمقاطع الفيديو التي تحتوي على فصول.",
|
||||
"TaskRefreshChapterImages": "استخراج صور الفصل",
|
||||
"TasksApplicationCategory": "تطبيق",
|
||||
"TaskDownloadMissingSubtitlesDescription": "يبحث في الإنترنت على الترجمات الناقصة استنادا على البيانات الوصفية.",
|
||||
"TaskDownloadMissingSubtitles": "تحميل الترجمات الناقصة",
|
||||
"TaskRefreshChannelsDescription": "يحدث معلومات قنوات الإنترنت.",
|
||||
"TaskRefreshChannels": "إعادة تحديث القنوات",
|
||||
"TaskCleanTranscodeDescription": "يحذف ملفات الترميز الأقدم من يوم واحد.",
|
||||
"TaskCleanTranscode": "حذف ما بمجلد الترميز",
|
||||
"TaskUpdatePluginsDescription": "تحميل وتثبيت الإضافات التي تم تفعيل التحديث التلقائي لها.",
|
||||
"TaskUpdatePlugins": "تحديث الإضافات",
|
||||
"TaskRefreshPeopleDescription": "يقوم بتحديث البيانات الوصفية للممثلين والمخرجين في مكتبة الوسائط الخاصة بك.",
|
||||
"TaskRefreshPeople": "إعادة تحميل الأشخاص",
|
||||
"TaskCleanLogsDescription": "يحذف السجلات الأقدم من {0} يوم.",
|
||||
"TaskCleanLogs": "حذف مسار السجل",
|
||||
"TaskCleanActivityLogDescription": "يحذف سجل الأنشطة الأقدم من الوقت الذي تم تحديده.",
|
||||
"TaskCleanActivityLog": "حذف سجل الأنشطة",
|
||||
"Default": "افتراضي",
|
||||
"Undefined": "غير معرف",
|
||||
"Forced": "ملحقة",
|
||||
"TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقتطع المساحة الحرة. تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تتضمن تعديلات في قاعدة البيانات قد تؤدي إلى تحسين الأداء.",
|
||||
"TaskOptimizeDatabase": "تحسين قاعدة البيانات",
|
||||
"TaskKeyframeExtractorDescription": "يستخرج الإطارات الرئيسية من ملفات الفيديو لإنشاء قوائم تشغيل HLS أكثر دقة. قد يستغرق تشغيل هذه المهمة وقتاً طويلاً.",
|
||||
"TaskKeyframeExtractor": "مستخرج الإطارات الرئيسية",
|
||||
"TaskKeyframeExtractorDescription": "يستخرج الإطارات الرئيسية من ملفات الفيديو لكي ينشئ قوائم تشغيل بث HTTP المباشر. قد تستمر هذه العملية لوقت طويل.",
|
||||
"TaskKeyframeExtractor": "مستخرج الإطار الرئيسي",
|
||||
"External": "خارجي",
|
||||
"HearingImpaired": "لضعاف السمع",
|
||||
"TaskRefreshTrickplayImages": "إنشاء صور معاينات التنقل (Trickplay)",
|
||||
"TaskRefreshTrickplayImagesDescription": "ينشئ صور معاينات التنقل السريع للفيديوهات في المكتبات المفعّلة.",
|
||||
"TaskAudioNormalization": "تطبيع الصوت",
|
||||
"TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.",
|
||||
"TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة",
|
||||
"TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني",
|
||||
"TaskExtractMediaSegments": "فحص مقاطع المحتوى",
|
||||
"TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).",
|
||||
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
|
||||
"TaskMoveTrickplayImagesDescription": "ينقل ملفات معاينات التنقل الحالية وفقاً لإعدادات المكتبة.",
|
||||
"HearingImpaired": "ضعاف السمع",
|
||||
"TaskRefreshTrickplayImages": "توليد صور المعاينة السريعة",
|
||||
"TaskRefreshTrickplayImagesDescription": "يُولّد معاينات تنقل سريع لمقاطع الفيديو ضمن المكتبات المفعّلة.",
|
||||
"TaskCleanCollectionsAndPlaylists": "حذف المجموعات وقوائم التشغيل",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "حذف عناصر من المجموعات وقوائم التشغيل التي لم تعد موجودة.",
|
||||
"TaskAudioNormalization": "تسوية الصوت",
|
||||
"TaskAudioNormalizationDescription": "مسح الملفات لتطبيع بيانات الصوت.",
|
||||
"TaskDownloadMissingLyrics": "تنزيل عبارات القصيدة",
|
||||
"TaskDownloadMissingLyricsDescription": "كلمات",
|
||||
"TaskExtractMediaSegments": "فحص مقاطع الوسائط",
|
||||
"TaskExtractMediaSegmentsDescription": "يستخرج مقاطع وسائط من إضافات MediaSegment المُفعّلة.",
|
||||
"TaskMoveTrickplayImages": "تغيير مكان صور المعاينة السريعة",
|
||||
"TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة.",
|
||||
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
|
||||
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
|
||||
"Original": "فريد",
|
||||
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}",
|
||||
"NameExtraBehindTheScenes": "خلف المشاهد",
|
||||
"NameExtraClip": "مقطع",
|
||||
"NameExtraDeletedScene": "المشهد المحذوف",
|
||||
"NameExtraInterview": "مقابلة",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "عيّنة",
|
||||
"NameExtraScene": "مشهد",
|
||||
"NameExtraShort": "قصير",
|
||||
"NameExtraThemeSong": "الاغنية السمة",
|
||||
"NameExtraThemeVideo": "الفيديو السمة",
|
||||
"NameExtraFeaturette": "فيلم قصير إضافي",
|
||||
"NameExtraTrailer": "إعلان ترويجي",
|
||||
"NameExtraUnknown": "إضافي"
|
||||
"CleanupUserDataTaskDescription": "مسح جميع بيانات المستخدم (حالة المشاهدة، والحالة المفضلة وما إلى ذلك) من الوسائط التي لم تعد موجودة لمدة 90 يومًا على الأقل."
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"Albums": "এলবাম",
|
||||
"Application": "আবেদন",
|
||||
"AppDeviceValues": "এপ্: {0}, ডিভাইচ: {1}",
|
||||
"Artists": "শিল্পী",
|
||||
"Channels": "চেনেলস",
|
||||
"Default": "ডিফল্ট",
|
||||
"AuthenticationSucceededWithUserName": "{0} সফলভাবে প্রমাণিত",
|
||||
"Books": "পুস্তক",
|
||||
"Movies": "চলচ্চিত্ৰ",
|
||||
"CameraImageUploadedFrom": "একটি নতুন ক্যামেরা চিত্র আপলোড করা হয়েছে {0}",
|
||||
"Collections": "সংগ্রহ",
|
||||
"HeaderFavoriteShows": "প্রিয় শোসমূহ",
|
||||
"Latest": "শেহতীয়া",
|
||||
"MessageApplicationUpdated": "জেলিফিন চাইভাৰ আপডেট কৰা হৈছে",
|
||||
"MixedContent": "মিশ্ৰিত সমগ্ৰতা",
|
||||
"NewVersionIsAvailable": "ডাউনলোড কৰিবলৈ জেলিফিন চাইভাৰৰ এটা নতুন সংস্কৰণ উপলব্ধ আছে.",
|
||||
"NotificationOptionCameraImageUploaded": "কেমেৰাৰ চিত্ৰ আপল'ড কৰা হ'ল",
|
||||
@@ -16,14 +21,20 @@
|
||||
"Folders": "ফোল্ডাৰ",
|
||||
"Forced": "বলপূর্বক",
|
||||
"Genres": "শ্রেণী",
|
||||
"HeaderAlbumArtists": "অ্যালবাম শিল্পী",
|
||||
"HeaderContinueWatching": "দেখা চালিয়ে যান",
|
||||
"FailedLoginAttemptWithUserName": "লগইন ব্যর্থ চেষ্টা কৰা হৈছে থেকে {0}",
|
||||
"HeaderFavoriteAlbums": "প্রিয় অ্যালবামসমূহ",
|
||||
"HeaderFavoriteArtists": "প্রিয় শিল্পীসমূহ",
|
||||
"HeaderFavoriteEpisodes": "প্রিয় পর্বসমূহ",
|
||||
"HeaderFavoriteSongs": "প্ৰিয় গীত",
|
||||
"HeaderLiveTV": "প্ৰতিবেদন টিভি",
|
||||
"HeaderNextUp": "পৰৱৰ্তী অংশ",
|
||||
"HeaderRecordingGroups": "অলংকৰণ গোষ্ঠীসমূহ",
|
||||
"HearingImpaired": "শ্ৰবণ অক্ষম",
|
||||
"HomeVideos": "ঘৰৰ ভিডিঅ'সমূহ",
|
||||
"Inherit": "উত্তপ্ত কৰা",
|
||||
"MessageServerConfigurationUpdated": "চাইভাৰ কনফিগাৰেশ্যন আপডেট কৰা হৈছে",
|
||||
"NotificationOptionApplicationUpdateAvailable": "অ্যাপ্লিকেশ্যন আপডেট উপলব্ধ",
|
||||
"NotificationOptionApplicationUpdateInstalled": "অ্যাপ্লিকেশ্যন আপডেট ইনষ্টল কৰা হ'ল",
|
||||
"NotificationOptionAudioPlayback": "অডিঅ' প্লেবেক আৰম্ভ হ'ল",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user