Compare commits

..

1 Commits

Author SHA1 Message Date
Andrew Rabert
627c1b977c Add standards check workflow
Adds a few checks to reduce the noise of invalid pull requests.
2026-06-01 17:50:07 -04:00
145 changed files with 1136 additions and 5582 deletions

View File

@@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"dotnet-ef": { "dotnet-ef": {
"version": "10.0.9", "version": "10.0.8",
"commands": [ "commands": [
"dotnet-ef" "dotnet-ef"
] ]

View File

@@ -87,7 +87,6 @@ body:
label: Jellyfin Server version label: Jellyfin Server version
description: What version of Jellyfin are you using? description: What version of Jellyfin are you using?
options: options:
- 10.11.11
- 10.11.10 - 10.11.10
- 10.11.9 - 10.11.9
- 10.11.8 - 10.11.8

View File

@@ -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). 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** **Changes**
<!-- Describe your changes here in 1-5 sentences. --> <!-- 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** **Issues**
<!-- Tag any issues that this PR solves here. <!-- Tag any issues that this PR solves here.
ex. Fixes # --> ex. Fixes # -->

View File

@@ -24,21 +24,21 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended queries: +security-extended
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0

View File

@@ -11,13 +11,13 @@ jobs:
permissions: read-all permissions: read-all
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
@@ -40,14 +40,14 @@ jobs:
permissions: read-all permissions: read-all
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0 fetch-depth: 0
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'

View File

@@ -15,9 +15,9 @@ jobs:
format-check: format-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with: with:
dotnet-version: ${{ env.SDK_VERSION }} dotnet-version: ${{ env.SDK_VERSION }}

View File

@@ -20,9 +20,9 @@ jobs:
runs-on: "${{ matrix.os }}" runs-on: "${{ matrix.os }}"
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with: with:
dotnet-version: ${{ env.SDK_VERSION }} dotnet-version: ${{ env.SDK_VERSION }}

View File

@@ -24,7 +24,7 @@ jobs:
reactions: '+1' reactions: '+1'
- name: Checkout the latest code - name: Checkout the latest code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
token: ${{ secrets.JF_BOT_TOKEN }} token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0 fetch-depth: 0
@@ -40,12 +40,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: pull in script - name: pull in script
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
repository: jellyfin/jellyfin-triage-script repository: jellyfin/jellyfin-triage-script
- name: install python - name: install python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: '3.14' python-version: '3.14'
cache: 'pip' cache: 'pip'

View File

@@ -10,12 +10,12 @@ jobs:
issues: write issues: write
steps: steps:
- name: pull in script - name: pull in script
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
repository: jellyfin/jellyfin-triage-script repository: jellyfin/jellyfin-triage-script
- name: install python - name: install python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: '3.14' python-version: '3.14'
cache: 'pip' cache: 'pip'

View File

@@ -22,13 +22,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ inputs.ref }} ref: ${{ inputs.ref }}
repository: ${{ inputs.repository }} repository: ${{ inputs.repository }}
- name: Configure .NET - name: Configure .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'

View File

@@ -10,7 +10,7 @@ jobs:
base_ref: ${{ steps.ancestor.outputs.base_ref }} base_ref: ${{ steps.ancestor.outputs.base_ref }}
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}

View File

@@ -5,19 +5,18 @@ on:
branches: branches:
- master - master
pull_request_target: pull_request_target:
types: [synchronize] issue_comment:
permissions: {} permissions: {}
jobs: jobs:
main: label:
name: Labeling
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }}
contents: read
pull-requests: write
if: ${{ github.repository == 'jellyfin/jellyfin' }}
steps: steps:
- name: Apply label - name: Apply label
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
with: with:
dirtyLabel: 'merge conflict' dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'

View File

@@ -0,0 +1,36 @@
name: Standards Check
on:
pull_request:
paths:
- '**/CLAUDE.md'
- '**/AGENTS.md'
- 'docs/superpowers/**'
jobs:
close:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'This PR does not follow our contributing guidelines. https://jellyfin.org/docs/general/contributing/'
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['invalid']
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
state: 'closed'
});

View File

@@ -33,7 +33,7 @@ jobs:
yq-version: v4.9.8 yq-version: v4.9.8
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ env.TAG_BRANCH }} ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ env.TAG_BRANCH }} ref: ${{ env.TAG_BRANCH }}

View File

@@ -90,7 +90,6 @@
- [mark-monteiro](https://github.com/mark-monteiro) - [mark-monteiro](https://github.com/mark-monteiro)
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti) - [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
- [Martin Reuter](https://github.com/reuterma24) - [Martin Reuter](https://github.com/reuterma24)
- [Matt Teahan](https://github.com/matt-teahan)
- [Matt07211](https://github.com/Matt07211) - [Matt07211](https://github.com/Matt07211)
- [Matthew Jones](https://github.com/matthew-jones-uk) - [Matthew Jones](https://github.com/matthew-jones-uk)
- [Maxr1998](https://github.com/Maxr1998) - [Maxr1998](https://github.com/Maxr1998)

View File

@@ -26,28 +26,28 @@
<PackageVersion Include="libse" Version="4.0.12" /> <PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Morestachio" Version="5.0.1.670" /> <PackageVersion Include="Morestachio" Version="5.0.1.670" />
<PackageVersion Include="Moq" Version="4.18.4" /> <PackageVersion Include="Moq" Version="4.18.4" />
@@ -57,27 +57,26 @@
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" /> <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageVersion Include="prometheus-net" Version="8.2.1" /> <PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="Polly" Version="8.7.0" /> <PackageVersion Include="Polly" Version="8.6.6" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" /> <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.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="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" /> <PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpCompress" Version="0.49.1" /> <PackageVersion Include="SharpFuzz" Version="2.2.0" />
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" /> <PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" /> <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" /> <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" /> <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="3.7.0" /> <PackageVersion Include="Svg.Skia" Version="3.7.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" /> <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.0" />
<PackageVersion Include="System.Text.Json" Version="10.0.9" /> <PackageVersion Include="System.Text.Json" Version="10.0.8" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="7.15.3" /> <PackageVersion Include="z440.atl.core" Version="7.15.3" />
<PackageVersion Include="TMDbLib" Version="3.0.0" /> <PackageVersion Include="TMDbLib" Version="3.0.0" />

View File

@@ -1,4 +1,3 @@
using System;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace Emby.Naming.Book namespace Emby.Naming.Book
@@ -6,7 +5,7 @@ namespace Emby.Naming.Book
/// <summary> /// <summary>
/// Helper class to retrieve basic metadata from a book filename. /// Helper class to retrieve basic metadata from a book filename.
/// </summary> /// </summary>
public static partial class BookFileNameParser public static class BookFileNameParser
{ {
private const string NameMatchGroup = "name"; private const string NameMatchGroup = "name";
private const string IndexMatchGroup = "index"; private const string IndexMatchGroup = "index";
@@ -16,17 +15,14 @@ namespace Emby.Naming.Book
private static readonly Regex[] _nameMatches = private static readonly Regex[] _nameMatches =
[ [
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required // 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(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\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(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"^(?<index>[0-9]+)(?:\.0)?\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"), new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"), new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
// last resort matches the whole string as the name // last resort matches the whole string as the name
new Regex(@"(?<name>.*)") new Regex(@"(?<name>.*)")
]; ];
[GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")]
private static partial Regex ComicRegex();
/// <summary> /// <summary>
/// Parse a filename name to retrieve the book name, series name, index, and year. /// Parse a filename name to retrieve the book name, series name, index, and year.
/// </summary> /// </summary>
@@ -52,22 +48,7 @@ namespace Emby.Naming.Book
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success) if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
{ {
var comicMatch = ComicRegex().Match(nameGroup.Value.Trim()); result.Name = 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)) if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index))

View File

@@ -1,3 +1,5 @@
using System;
namespace Emby.Naming.Book namespace Emby.Naming.Book
{ {
/// <summary> /// <summary>
@@ -12,7 +14,6 @@ namespace Emby.Naming.Book
{ {
Name = null; Name = null;
Index = null; Index = null;
ParentIndex = null;
Year = null; Year = null;
SeriesName = null; SeriesName = null;
} }
@@ -27,11 +28,6 @@ namespace Emby.Naming.Book
/// </summary> /// </summary>
public int? Index { get; set; } public int? Index { get; set; }
/// <summary>
/// Gets or sets the parent index number.
/// </summary>
public int? ParentIndex { get; set; }
/// <summary> /// <summary>
/// Gets or sets the publication year. /// Gets or sets the publication year.
/// </summary> /// </summary>

View File

@@ -25,11 +25,5 @@ namespace Emby.Naming.TV
/// </summary> /// </summary>
/// <value>The name of the series.</value> /// <value>The name of the series.</value>
public string? Name { get; set; } 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; }
} }
} }

View File

@@ -21,7 +21,7 @@ namespace Emby.Naming.TV
/// Regex that matches titles with year in parentheses. Captures the title (which may be /// 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". /// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
/// </summary> /// </summary>
[GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")] [GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")]
private static partial Regex TitleWithYearRegex(); private static partial Regex TitleWithYearRegex();
/// <summary> /// <summary>
@@ -43,8 +43,7 @@ namespace Emby.Naming.TV
seriesName = titleWithYearMatch.Groups["title"].Value.Trim(); seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
return new SeriesInfo(path) return new SeriesInfo(path)
{ {
Name = seriesName, Name = seriesName
Year = int.TryParse(titleWithYearMatch.Groups["year"].ValueSpan, out var year) ? year : null
}; };
} }
} }

View File

@@ -26,7 +26,6 @@ using Emby.Server.Implementations.Dto;
using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.HttpServer.Security;
using Emby.Server.Implementations.IO; using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library; using Emby.Server.Implementations.Library;
using Emby.Server.Implementations.Library.Search;
using Emby.Server.Implementations.Library.SimilarItems; using Emby.Server.Implementations.Library.SimilarItems;
using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Localization;
using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Playlists;
@@ -93,9 +92,6 @@ using MediaBrowser.Model.Net;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.System; using MediaBrowser.Model.System;
using MediaBrowser.Model.Tasks; 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.Lyric;
using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Plugins.ListenBrainz; using MediaBrowser.Providers.Plugins.ListenBrainz;
@@ -499,14 +495,6 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ListenBrainzLabsClient>(); serviceCollection.AddSingleton<ListenBrainzLabsClient>();
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>(); 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); serviceCollection.AddSingleton(NetManager);
serviceCollection.AddSingleton<ITaskManager, TaskManager>(); serviceCollection.AddSingleton<ITaskManager, TaskManager>();
@@ -551,7 +539,6 @@ namespace Emby.Server.Implementations
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>)); serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>(); serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
serviceCollection.AddSingleton<NamingOptions>(); serviceCollection.AddSingleton<NamingOptions>();
serviceCollection.AddSingleton<VideoListResolver>(); serviceCollection.AddSingleton<VideoListResolver>();
@@ -563,8 +550,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>(); serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
serviceCollection.AddSingleton<ISearchManager, SearchManager>(); serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
@@ -723,7 +709,6 @@ namespace Emby.Server.Implementations
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>()); Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
} }
/// <summary> /// <summary>

View File

@@ -1539,21 +1539,6 @@ namespace Emby.Server.Implementations.Dto
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner) private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
{ {
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) if (!item.SupportsInheritedParentImages)
{ {
return; return;

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Chapters;
@@ -51,33 +52,26 @@ public class ExternalDataManager : IExternalDataManager
/// <inheritdoc/> /// <inheritdoc/>
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken) public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
{ {
DeleteExternalItemFiles(item); var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
var itemId = item.Id; 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 _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false); await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _chapterManager.DeleteChapterDataAsync(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);
}
}
}
} }

View File

@@ -89,7 +89,6 @@ namespace Emby.Server.Implementations.Library
private readonly FastConcurrentLru<Guid, BaseItem> _cache; private readonly FastConcurrentLru<Guid, BaseItem> _cache;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
private readonly IMediaStreamRepository _mediaStreamRepository; private readonly IMediaStreamRepository _mediaStreamRepository;
private readonly Lazy<IExternalDataManager> _externalDataManagerFactory;
/// <summary> /// <summary>
/// The _root folder sync lock. /// The _root folder sync lock.
@@ -133,7 +132,6 @@ namespace Emby.Server.Implementations.Library
/// <param name="pathManager">The path manager.</param> /// <param name="pathManager">The path manager.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
/// <param name="mediaStreamRepository">The media stream repository.</param> /// <param name="mediaStreamRepository">The media stream repository.</param>
/// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
public LibraryManager( public LibraryManager(
IServerApplicationHost appHost, IServerApplicationHost appHost,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
@@ -157,8 +155,7 @@ namespace Emby.Server.Implementations.Library
IPeopleRepository peopleRepository, IPeopleRepository peopleRepository,
IPathManager pathManager, IPathManager pathManager,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule, DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
IMediaStreamRepository mediaStreamRepository, IMediaStreamRepository mediaStreamRepository)
Lazy<IExternalDataManager> externalDataManagerFactory)
{ {
_appHost = appHost; _appHost = appHost;
_logger = loggerFactory.CreateLogger<LibraryManager>(); _logger = loggerFactory.CreateLogger<LibraryManager>();
@@ -189,7 +186,6 @@ namespace Emby.Server.Implementations.Library
_configurationManager.ConfigurationUpdated += ConfigurationUpdated; _configurationManager.ConfigurationUpdated += ConfigurationUpdated;
_mediaStreamRepository = mediaStreamRepository; _mediaStreamRepository = mediaStreamRepository;
_externalDataManagerFactory = externalDataManagerFactory;
RecordConfigurationValues(_configurationManager.Configuration); RecordConfigurationValues(_configurationManager.Configuration);
} }
@@ -400,12 +396,6 @@ namespace Emby.Server.Implementations.Library
} }
} }
var externalDataManager = _externalDataManagerFactory.Value;
foreach (var (item, _, _) in pathMaps)
{
externalDataManager.DeleteExternalItemFiles(item);
}
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); _persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
} }
@@ -586,13 +576,6 @@ namespace Emby.Server.Implementations.Library
item.SetParent(null); item.SetParent(null);
var externalDataManager = _externalDataManagerFactory.Value;
externalDataManager.DeleteExternalItemFiles(item);
foreach (var child in children)
{
externalDataManager.DeleteExternalItemFiles(child);
}
_persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]); _persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]);
_cache.TryRemove(item.Id, out _); _cache.TryRemove(item.Id, out _);
foreach (var child in children) foreach (var child in children)
@@ -2004,8 +1987,7 @@ namespace Emby.Server.Implementations.Library
query.TopParentIds.Length == 0 && query.TopParentIds.Length == 0 &&
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) && string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) && string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
query.ItemIds.Length == 0 && query.ItemIds.Length == 0)
query.OwnerIds.Length == 0)
{ {
var userViews = UserViewManager.GetUserViews(new UserViewQuery var userViews = UserViewManager.GetUserViews(new UserViewQuery
{ {
@@ -2450,14 +2432,8 @@ namespace Emby.Server.Implementations.Library
var outdated = forceUpdate var outdated = forceUpdate
? item.ImageInfos.Where(i => i.Path is not null).ToArray() ? item.ImageInfos.Where(i => i.Path is not null).ToArray()
: item.ImageInfos.Where(ImageNeedsRefresh).ToArray(); : item.ImageInfos.Where(ImageNeedsRefresh).ToArray();
// Skip image processing if current or live tv source
var parentItem = item.GetParent(); if (outdated.Length == 0 || item.SourceType != SourceType.Library)
var isLiveTvShow = item.SourceType != SourceType.Library &&
parentItem is not null &&
parentItem.SourceType != SourceType.Library; // not a channel
// Skip image processing if current or live tv show
if (outdated.Length == 0 || isLiveTvShow)
{ {
RegisterItem(item); RegisterItem(item);
return; return;

View File

@@ -229,7 +229,7 @@ namespace Emby.Server.Implementations.Library
list.Add(source); list.Add(source);
} }
return SortMediaSources(list, item.Id).ToArray(); return SortMediaSources(list).ToArray();
} }
/// <inheritdoc />> /// <inheritdoc />>
@@ -386,12 +386,6 @@ namespace Emby.Server.Implementations.Library
if (user is not null) 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) foreach (var source in sources)
{ {
SetDefaultAudioAndSubtitleStreamIndices(item, source, user); SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
@@ -546,32 +540,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. return sources.OrderBy(i =>
var preferredId = preferredItemId.IsEmpty() {
? null if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
: preferredItemId.ToString("N", CultureInfo.InvariantCulture);
return sources
.OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
.ThenBy(i =>
{ {
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) return 0;
{ }
return 0;
}
return 1; return 1;
}) }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
.ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) .ThenByDescending(i =>
.ThenByDescending(i => {
{ var stream = i.VideoStream;
var stream = i.VideoStream;
return stream?.Width ?? 0; return stream?.Width ?? 0;
}) })
.Where(i => i.Type != MediaSourceType.Placeholder); .Where(i => i.Type != MediaSourceType.Placeholder);
} }
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)

View File

@@ -121,11 +121,7 @@ public class PathManager : IPathManager
} }
paths.Add(GetTrickplayDirectory(item, false)); paths.Add(GetTrickplayDirectory(item, false));
if (!string.IsNullOrEmpty(item.Path)) paths.Add(GetTrickplayDirectory(item, true));
{
paths.Add(GetTrickplayDirectory(item, true));
}
paths.Add(GetChapterImageFolderPath(item)); paths.Add(GetChapterImageFolderPath(item));
return paths; return paths;

View File

@@ -1,3 +1,5 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@@ -16,7 +18,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
{ {
private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; 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(); var collectionType = args.GetCollectionType();
@@ -45,14 +47,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
Path = args.Path, Path = args.Path,
Name = result.Name ?? string.Empty, Name = result.Name ?? string.Empty,
IndexNumber = result.Index, IndexNumber = result.Index,
ParentIndexNumber = result.ParentIndex,
ProductionYear = result.Year, ProductionYear = result.Year,
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)), SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
IsInMixedFolder = true, IsInMixedFolder = true,
}; };
} }
private Book? GetBook(ItemResolveArgs args) private Book GetBook(ItemResolveArgs args)
{ {
var bookFiles = args.FileSystemChildren.Where(f => var bookFiles = args.FileSystemChildren.Where(f =>
{ {
@@ -77,7 +78,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
Path = bookFiles[0].FullName, Path = bookFiles[0].FullName,
Name = result.Name ?? string.Empty, Name = result.Name ?? string.Empty,
IndexNumber = result.Index, IndexNumber = result.Index,
ParentIndexNumber = result.ParentIndex,
ProductionYear = result.Year, ProductionYear = result.Year,
SeriesName = result.SeriesName ?? string.Empty, SeriesName = result.SeriesName ?? string.Empty,
}; };

View File

@@ -57,11 +57,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return null; return null;
} }
if (args.Parent is not null && args.Parent.IsRoot)
{
return null;
}
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path); var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
var collectionType = args.GetCollectionType(); var collectionType = args.GetCollectionType();
@@ -74,8 +69,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return new Series return new Series
{ {
Path = args.Path, Path = args.Path,
Name = seriesInfo.Name, Name = seriesInfo.Name
ProductionYear = seriesInfo.Year
}; };
} }
} }

View File

@@ -1,458 +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);
var fromExternal = externalResults.Count > 0;
IReadOnlyList<SearchResult> results;
if (fromExternal)
{
results = externalResults;
}
else
{
results = await internalTask.ConfigureAwait(false);
if (_internalProviders.Length > 0)
{
_logger.LogDebug("No results from external providers, using internal provider results");
}
}
// 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. Run the post-filter only
// when results came from externals 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 (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
{
var user = _userManager.GetUserById(query.UserId.Value);
if (user is not null)
{
results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
}
}
return results;
}
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
IReadOnlyList<SearchResult> candidates,
User user,
CancellationToken cancellationToken)
{
// SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
// TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
// because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
var accessFilter = new InternalItemsQuery(user);
_libraryManager.ConfigureUserAccess(accessFilter, user);
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 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.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);
}
}
}

View File

@@ -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.UserId);
// 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,
Guid? userId)
{
if (!userId.HasValue || userId.Value.IsEmpty())
{
return query;
}
var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return query;
}
var accessFilter = new InternalItemsQuery(user);
_libraryManager.ConfigureUserAccess(accessFilter, user);
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;
}
}

View File

@@ -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 && query.Limit.Value > 0)
{
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();
}
}
}

View File

@@ -1,19 +0,0 @@
{
"Books": "Kitablar",
"HomeVideos": "Ev Videoları",
"Latest": "Ən son",
"MixedContent": "Qarışıq məzmun",
"Movies": "Filmlər",
"Music": "Musiqi",
"MusicVideos": "Musiqi Videoları",
"NameSeasonUnknown": "Mövsüm Naməlum",
"NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.",
"NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur",
"NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb",
"NotificationOptionAudioPlayback": "Audio oxutma başladı",
"NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı",
"NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi",
"NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu",
"NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi",
"NotificationOptionPluginError": "Plugin uğursuzluğu"
}

View File

@@ -50,7 +50,7 @@
"ScheduledTaskFailedWithName": "{0} αποτυχία", "ScheduledTaskFailedWithName": "{0} αποτυχία",
"Shows": "Σειρές", "Shows": "Σειρές",
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.", "StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
"SubtitleDownloadFailureFromForItem": "Αποτυχία λήψης υποτίτλων από {0} για {1}", "SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
"TvShows": "Τηλεοπτικές Σειρές", "TvShows": "Τηλεοπτικές Σειρές",
"UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε", "UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε",
"UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί", "UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί",
@@ -106,7 +106,5 @@
"TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων", "TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων",
"TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.", "TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.", "CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη", "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη"
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
"Original": "Πρωτότυπο"
} }

View File

@@ -106,7 +106,5 @@
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location", "TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.", "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
"CleanupUserDataTask": "User data cleanup task", "CleanupUserDataTask": "User data cleanup task",
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.", "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days."
"LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
"Original": "Original"
} }

View File

@@ -106,7 +106,5 @@
"TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.", "TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
"TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay", "TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay",
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
"CleanupUserDataTask": "Tarea de limpieza de datos de usuarios", "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios"
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
"Original": "Original"
} }

View File

@@ -106,6 +106,5 @@
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización", "TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.", "TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios", "CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.", "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
"Original": "Orixinal"
} }

View File

@@ -106,7 +106,5 @@
"TaskExtractMediaSegments": "Scan Segmen media", "TaskExtractMediaSegments": "Scan Segmen media",
"TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay", "TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay",
"TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang", "TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang",
"CleanupUserDataTask": "Tugas Pembersihan Data Pengguna", "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna"
"LyricDownloadFailureFromForItem": "Lirik gagal di download dari {0} untuk {1}",
"Original": "Asli"
} }

View File

@@ -80,7 +80,7 @@
"NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ", "NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ",
"NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ", "NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ",
"NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", "NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
"NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
"NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
"NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ", "NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ",
"NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ", "NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ",

View File

@@ -106,7 +106,5 @@
"TaskDownloadMissingLyrics": "누락된 가사 다운로드", "TaskDownloadMissingLyrics": "누락된 가사 다운로드",
"TaskDownloadMissingLyricsDescription": "가사 다운로드", "TaskDownloadMissingLyricsDescription": "가사 다운로드",
"CleanupUserDataTask": "사용자 데이터 정리 작업", "CleanupUserDataTask": "사용자 데이터 정리 작업",
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.", "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다."
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
"Original": "원본"
} }

View File

@@ -107,6 +107,5 @@
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
"CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.", "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
"CleanupUserDataTask": "Limpeza de dados de utilizador", "CleanupUserDataTask": "Limpeza de dados de utilizador",
"Original": "Original", "Original": "Original"
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
} }

View File

@@ -106,7 +106,5 @@
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní", "TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne", "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
"CleanupUserDataTask": "Prečistiť používateľské dáta", "CleanupUserDataTask": "Prečistiť používateľské dáta",
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.", "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní."
"LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
"Original": "Originál"
} }

View File

@@ -106,7 +106,5 @@
"TaskAudioNormalization": "Normalizacija zvoka", "TaskAudioNormalization": "Normalizacija zvoka",
"TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.", "TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.",
"CleanupUserDataTask": "Čiščenje uporabniških podatkov", "CleanupUserDataTask": "Čiščenje uporabniških podatkov",
"CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.", "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo."
"LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
"Original": "Original"
} }

View File

@@ -106,7 +106,5 @@
"CleanupUserDataTask": "Задатак чишћења корисничких података", "CleanupUserDataTask": "Задатак чишћења корисничких података",
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.", "CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање", "TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама", "TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
"Original": "Изворно"
} }

View File

@@ -1,5 +1 @@
{ {}
"Artists": "Wasanii",
"Books": "Vitabu",
"Collections": "Mikusanyiko"
}

View File

@@ -106,7 +106,5 @@
"TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay快轉預覽檔案搬去對應位置。", "TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay快轉預覽檔案搬去對應位置。",
"TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置", "TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置",
"CleanupUserDataTask": "清理使用者資料嘅任務", "CleanupUserDataTask": "清理使用者資料嘅任務",
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。", "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。"
"LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
"Original": "原始"
} }

View File

@@ -566,15 +566,11 @@ namespace Emby.Server.Implementations.Localization
private static string GetResourceFilename(string culture) private static string GetResourceFilename(string culture)
{ {
// Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator. var parts = culture.Split('-');
// Normalize the casing (lower-case language, upper-case region) while preserving the separator
// so the result matches the embedded resource file name, which is case-sensitive.
var separatorIndex = culture.IndexOfAny(['-', '_']);
if (separatorIndex > 0) if (parts.Length == 2)
{ {
var separator = culture[separatorIndex]; culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant();
} }
else else
{ {

View File

@@ -92,8 +92,7 @@ public class ChapterImagesTask : IScheduledTask
EnableImages = false EnableImages = false
}, },
SourceTypes = [SourceType.Library], SourceTypes = [SourceType.Library],
IsVirtualItem = false, IsVirtualItem = false
IncludeOwnedItems = true
}) })
.OfType<Video>() .OfType<Video>()
.ToList(); .ToList();

View File

@@ -68,7 +68,6 @@ public class MediaSegmentExtractionTask : IScheduledTask
DtoOptions = new DtoOptions(true), DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library], SourceTypes = [SourceType.Library],
Recursive = true, Recursive = true,
IncludeOwnedItems = true,
Limit = pagesize Limit = pagesize
}; };

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -18,7 +17,6 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
private readonly ILogger<OptimizeDatabaseTask> _logger; private readonly ILogger<OptimizeDatabaseTask> _logger;
private readonly ILocalizationManager _localization; private readonly ILocalizationManager _localization;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly ILibraryManager _libraryManager;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
@@ -26,17 +24,14 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param> /// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public OptimizeDatabaseTask( public OptimizeDatabaseTask(
ILogger<OptimizeDatabaseTask> logger, ILogger<OptimizeDatabaseTask> logger,
ILocalizationManager localization, ILocalizationManager localization,
IJellyfinDatabaseProvider jellyfinDatabaseProvider, IJellyfinDatabaseProvider jellyfinDatabaseProvider)
ILibraryManager libraryManager)
{ {
_logger = logger; _logger = logger;
_localization = localization; _localization = localization;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider; _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
_libraryManager = libraryManager;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -73,15 +68,6 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
/// <inheritdoc /> /// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{ {
// Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in
// progress causes both operations to contend for the database and can stall the scan, so defer optimization
// until no scan is running. The task will run again on its next trigger.
if (_libraryManager.IsScanRunning)
{
_logger.LogInformation("Skipping database optimization because a library scan is currently running.");
return;
}
_logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); _logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
try try

View File

@@ -9,7 +9,6 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks; namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
@@ -21,7 +20,6 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly ILocalizationManager _localization; private readonly ILocalizationManager _localization;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly ILogger<PeopleValidationTask> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class. /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
@@ -29,13 +27,11 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param> /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param> public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory)
public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory, ILogger<PeopleValidationTask> logger)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
_localization = localization; _localization = localization;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_logger = logger;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -75,18 +71,13 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <inheritdoc /> /// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{ {
// People validation performs heavy database writes that contend with an active library scan. IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
// Defer it until the scan has finished; the task will run again on its next trigger. await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false);
if (_libraryManager.IsScanRunning)
{
_logger.LogInformation("Skipping people validation because a library scan is currently running.");
return;
}
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false)) await using (context.ConfigureAwait(false))
{ {
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
var dupQuery = context.Peoples var dupQuery = context.Peoples
.GroupBy(e => new { e.Name, e.PersonType }) .GroupBy(e => new { e.Name, e.PersonType })
.Where(e => e.Count() > 1) .Where(e => e.Count() > 1)
@@ -132,18 +123,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
ArrayPool<Guid[]>.Shared.Return(buffer); ArrayPool<Guid[]>.Shared.Return(buffer);
} }
var peopleToDelete = await context.Peoples
.Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id)))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete);
subProgress.Report(100); subProgress.Report(100);
} }
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
progress.Report(100);
} }
} }

View File

@@ -343,10 +343,6 @@ namespace Emby.Server.Implementations.Session
_activeLiveStreamSessions.TryRemove(liveStreamId, out _); _activeLiveStreamSessions.TryRemove(liveStreamId, out _);
} }
} }
else
{
liveStreamNeedsToBeClosed = true;
}
if (liveStreamNeedsToBeClosed) if (liveStreamNeedsToBeClosed)
{ {

View File

@@ -206,8 +206,7 @@ namespace Emby.Server.Implementations.SyncPlay
foreach (var itemId in queue) foreach (var itemId in queue)
{ {
var item = _libraryManager.GetItemById(itemId); var item = _libraryManager.GetItemById(itemId);
if (!item.IsVisibleStandalone(user))
if (item is null || !item.IsVisibleStandalone(user))
{ {
return false; return false;
} }

View File

@@ -1,5 +1,4 @@
using System; using System;
using System.Buffers;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
@@ -33,8 +32,6 @@ namespace Emby.Server.Implementations.Updates
/// </summary> /// </summary>
public class InstallationManager : IInstallationManager public class InstallationManager : IInstallationManager
{ {
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
/// <summary> /// <summary>
/// The logger. /// The logger.
/// </summary> /// </summary>
@@ -524,27 +521,9 @@ namespace Emby.Server.Implementations.Updates
return; return;
} }
if (!IsValidPackageDirectoryName(package.Name))
{
_logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name);
throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name.");
}
// Always override the passed-in target (which is a file) and figure it out again // Always override the passed-in target (which is a file) and figure it out again
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath));
var resolvedTarget = Path.GetFullPath(targetDir);
if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.",
package.Name,
resolvedTarget,
pluginsRoot);
throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
}
using var response = await _httpClientFactory.CreateClient(NamedClient.Default) using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -593,26 +572,6 @@ namespace Emby.Server.Implementations.Updates
_pluginManager.ImportPluginFrom(targetDir); _pluginManager.ImportPluginFrom(targetDir);
} }
private static bool IsValidPackageDirectoryName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal))
{
return false;
}
if (name.IndexOfAny(InvalidPackageNameChars) >= 0)
{
return false;
}
return true;
}
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
{ {
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version)) LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))

View File

@@ -288,7 +288,7 @@ public class ItemUpdateController : BaseJellyfinApiController
item.CustomRating = request.CustomRating; item.CustomRating = request.CustomRating;
var currentTags = item.Tags; var currentTags = item.Tags;
var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var newTags = request.Tags.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var removedTags = currentTags.Except(newTags).ToList(); var removedTags = currentTags.Except(newTags).ToList();
var addedTags = newTags.Except(currentTags).ToList(); var addedTags = newTags.Except(currentTags).ToList();
item.Tags = newTags; item.Tags = newTags;

View File

@@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -43,7 +42,6 @@ public class ItemsController : BaseJellyfinApiController
private readonly ILogger<ItemsController> _logger; private readonly ILogger<ItemsController> _logger;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly IUserDataManager _userDataRepository; private readonly IUserDataManager _userDataRepository;
private readonly ISearchManager _searchManager;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ItemsController"/> class. /// Initializes a new instance of the <see cref="ItemsController"/> class.
@@ -55,7 +53,6 @@ public class ItemsController : BaseJellyfinApiController
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
/// <param name="searchManager">Instance of the <see cref="ISearchManager"/> interface.</param>
public ItemsController( public ItemsController(
IUserManager userManager, IUserManager userManager,
ILibraryManager libraryManager, ILibraryManager libraryManager,
@@ -63,8 +60,7 @@ public class ItemsController : BaseJellyfinApiController
IDtoService dtoService, IDtoService dtoService,
ILogger<ItemsController> logger, ILogger<ItemsController> logger,
ISessionManager sessionManager, ISessionManager sessionManager,
IUserDataManager userDataRepository, IUserDataManager userDataRepository)
ISearchManager searchManager)
{ {
_userManager = userManager; _userManager = userManager;
_libraryManager = libraryManager; _libraryManager = libraryManager;
@@ -73,7 +69,6 @@ public class ItemsController : BaseJellyfinApiController
_logger = logger; _logger = logger;
_sessionManager = sessionManager; _sessionManager = sessionManager;
_userDataRepository = userDataRepository; _userDataRepository = userDataRepository;
_searchManager = searchManager;
} }
/// <summary> /// <summary>
@@ -319,23 +314,22 @@ public class ItemsController : BaseJellyfinApiController
if (collectionType == CollectionType.playlists) if (collectionType == CollectionType.playlists)
{ {
recursive = true; recursive = true;
includeItemTypes = [BaseItemKind.Playlist]; includeItemTypes = new[] { BaseItemKind.Playlist };
} }
else if (folder is ICollectionFolder && includeItemTypes.Length == 0) else if (folder is ICollectionFolder)
{
includeItemTypes = collectionType switch
{
CollectionType.boxsets => [BaseItemKind.BoxSet],
null => [BaseItemKind.Movie, BaseItemKind.Series],
_ => []
};
}
// includeItemTypes on a library lists its contents recursively rather than just its
// immediate children, so default to a recursive query when the client didn't choose.
if (folder is ICollectionFolder && includeItemTypes.Length > 0)
{ {
// When the client doesn't specify recursive/includeItemTypes, force the query
// through the database path where all filters (IsHD, genres, etc.) are applied.
recursive ??= true; recursive ??= true;
if (includeItemTypes.Length == 0)
{
includeItemTypes = collectionType switch
{
CollectionType.boxsets => [BaseItemKind.BoxSet],
null => [BaseItemKind.Movie, BaseItemKind.Series],
_ => []
};
}
} }
if (item is not UserRootFolder if (item is not UserRootFolder
@@ -348,273 +342,218 @@ public class ItemsController : BaseJellyfinApiController
return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
} }
// Build the query up front so the dispatch below can decide the path from it. if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
// Use search providers when searchTerm is provided. Providers return only IDs and scores;
// items are loaded server-side via folder.GetItems below, which applies user-access filtering.
Dictionary<Guid, float>? searchResultScores = null;
Guid[] itemIds = ids;
if (!string.IsNullOrWhiteSpace(searchTerm))
{ {
var searchProviderQuery = new SearchProviderQuery var query = new InternalItemsQuery(user)
{ {
SearchTerm = searchTerm, IsPlayed = isPlayed,
UserId = userId, MediaTypes = mediaTypes,
IncludeItemTypes = includeItemTypes, IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = excludeItemTypes, ExcludeItemTypes = excludeItemTypes,
MediaTypes = mediaTypes, Recursive = recursive ?? false,
Limit = limit.HasValue ? limit.Value * 3 : null, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
ParentId = parentId IsFavorite = isFavorite,
Limit = limit,
StartIndex = startIndex,
IsMissing = isMissing,
IsUnaired = isUnaired,
CollapseBoxSetItems = collapseBoxSetItems,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
HasImdbId = hasImdbId,
IsPlaceHolder = isPlaceHolder,
IsLocked = isLocked,
MinWidth = minWidth,
MinHeight = minHeight,
MaxWidth = maxWidth,
MaxHeight = maxHeight,
Is3D = is3D,
HasTvdbId = hasTvdbId,
HasTmdbId = hasTmdbId,
IsMovie = isMovie,
IsSeries = isSeries,
IsNews = isNews,
IsKids = isKids,
IsSports = isSports,
HasOverview = hasOverview,
HasOfficialRating = hasOfficialRating,
HasParentalRating = hasParentalRating,
HasSpecialFeature = hasSpecialFeature,
HasSubtitles = hasSubtitles,
HasThemeSong = hasThemeSong,
HasThemeVideo = hasThemeVideo,
HasTrailer = hasTrailer,
IsHD = isHd,
Is4K = is4K,
Tags = tags,
OfficialRatings = officialRatings,
Genres = genres,
ArtistIds = artistIds,
AlbumArtistIds = albumArtistIds,
ContributingArtistIds = contributingArtistIds,
GenreIds = genreIds,
StudioIds = studioIds,
Person = person,
PersonIds = personIds,
PersonTypes = personTypes,
Years = years,
ImageTypes = imageTypes,
VideoTypes = videoTypes,
AdjacentTo = adjacentTo,
ItemIds = ids,
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
ParentId = parentId ?? Guid.Empty,
IndexNumber = indexNumber,
ParentIndexNumber = parentIndexNumber,
EnableTotalRecordCount = enableTotalRecordCount,
ExcludeItemIds = excludeItemIds,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
MinPremiereDate = minPremiereDate?.ToUniversalTime(),
MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
AudioLanguages = audioLanguages,
SubtitleLanguages = subtitleLanguages,
LinkedChildAncestorIds = linkedChildAncestorIds,
}; };
var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false); if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
if (searchResults.Count > 0)
{ {
searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score); query.CollapseBoxSetItems = false;
itemIds = ids.Length > 0
? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
: searchResultScores.Keys.ToArray();
} }
}
var query = new InternalItemsQuery(user) if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
{
IsPlayed = isPlayed,
MediaTypes = mediaTypes,
IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = excludeItemTypes,
Recursive = recursive ?? false,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
IsFavorite = isFavorite,
Limit = searchResultScores is null ? limit : null,
StartIndex = searchResultScores is null ? startIndex : null,
IsMissing = isMissing,
IsUnaired = isUnaired,
CollapseBoxSetItems = collapseBoxSetItems,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
HasImdbId = hasImdbId,
IsPlaceHolder = isPlaceHolder,
IsLocked = isLocked,
MinWidth = minWidth,
MinHeight = minHeight,
MaxWidth = maxWidth,
MaxHeight = maxHeight,
Is3D = is3D,
HasTvdbId = hasTvdbId,
HasTmdbId = hasTmdbId,
IsMovie = isMovie,
IsSeries = isSeries,
IsNews = isNews,
IsKids = isKids,
IsSports = isSports,
HasOverview = hasOverview,
HasOfficialRating = hasOfficialRating,
HasParentalRating = hasParentalRating,
HasSpecialFeature = hasSpecialFeature,
HasSubtitles = hasSubtitles,
HasThemeSong = hasThemeSong,
HasThemeVideo = hasThemeVideo,
HasTrailer = hasTrailer,
IsHD = isHd,
Is4K = is4K,
Tags = tags,
OfficialRatings = officialRatings,
Genres = genres,
ArtistIds = artistIds,
AlbumArtistIds = albumArtistIds,
ContributingArtistIds = contributingArtistIds,
GenreIds = genreIds,
StudioIds = studioIds,
Person = person,
PersonIds = personIds,
PersonTypes = personTypes,
Years = years,
ImageTypes = imageTypes,
VideoTypes = videoTypes,
AdjacentTo = adjacentTo,
ItemIds = itemIds,
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
ParentId = parentId ?? Guid.Empty,
IndexNumber = indexNumber,
ParentIndexNumber = parentIndexNumber,
EnableTotalRecordCount = enableTotalRecordCount,
ExcludeItemIds = excludeItemIds,
DtoOptions = dtoOptions,
SearchTerm = searchResultScores is null ? searchTerm : null,
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
MinPremiereDate = minPremiereDate?.ToUniversalTime(),
MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
AudioLanguages = audioLanguages,
SubtitleLanguages = subtitleLanguages,
LinkedChildAncestorIds = linkedChildAncestorIds,
};
if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
{
query.CollapseBoxSetItems = false;
}
if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
{
if (query.HasSubtitles.Value)
{ {
// if we check for specific subtitles we don't need a separate check for subtitle existence if (query.HasSubtitles.Value)
query.HasSubtitles = null; {
// if we check for specific subtitles we don't need a separate check for subtitle existence
query.HasSubtitles = null;
}
else
{
// if we search for items without subtitles, we don't need to check for subtitles of a specific language
query.SubtitleLanguages = [];
}
} }
else
// for filter values that rely on media streams, we need to include alternative and linked versions
if (query.HasSubtitles.HasValue
|| query.SubtitleLanguages.Count > 0
|| query.AudioLanguages.Count > 0
|| query.Is3D.HasValue
|| query.IsHD.HasValue
|| query.Is4K.HasValue
|| query.VideoTypes.Length > 0
)
{ {
// if we search for items without subtitles, we don't need to check for subtitles of a specific language query.IncludeOwnedItems = true;
query.SubtitleLanguages = [];
} }
}
// for filter values that rely on media streams, we need to include alternative and linked versions query.ApplyFilters(filters);
if (query.HasSubtitles.HasValue
|| query.SubtitleLanguages.Count > 0
|| query.AudioLanguages.Count > 0
|| query.Is3D.HasValue
|| query.IsHD.HasValue
|| query.Is4K.HasValue
|| query.VideoTypes.Length > 0
)
{
query.IncludeOwnedItems = true;
}
query.ApplyFilters(filters); // Filter by Series Status
if (seriesStatus.Length != 0)
// Filter by Series Status
if (seriesStatus.Length != 0)
{
query.SeriesStatuses = seriesStatus;
}
// Exclude Blocked Unrated Items
var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems);
if (blockedUnratedItems is not null)
{
query.BlockUnratedItems = blockedUnratedItems;
}
// ExcludeLocationTypes
if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
{
query.IsVirtualItem = false;
}
if (locationTypes.Length > 0 && locationTypes.Length < 4)
{
query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
}
// Min official rating
if (!string.IsNullOrWhiteSpace(minOfficialRating))
{
query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
}
// Max official rating
if (!string.IsNullOrWhiteSpace(maxOfficialRating))
{
query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
}
// Artists
if (artists.Length != 0)
{
query.ArtistIds = artists.Select(i =>
{ {
try query.SeriesStatuses = seriesStatus;
{
return _libraryManager.GetArtist(i, new DtoOptions(false));
}
catch
{
return null;
}
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
// ExcludeArtistIds
if (excludeArtistIds.Length != 0)
{
query.ExcludeArtistIds = excludeArtistIds;
}
if (albumIds.Length != 0)
{
query.AlbumIds = albumIds;
}
// Albums
if (albums.Length != 0)
{
query.AlbumIds = albums.SelectMany(i =>
{
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
}).ToArray();
}
// Studios
if (studios.Length != 0)
{
query.StudioIds = studios.Select(i =>
{
try
{
return _libraryManager.GetStudio(i);
}
catch
{
return null;
}
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
// Apply default sorting if none requested
if (query.OrderBy.Count == 0)
{
// Albums by artist
if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
{
query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
} }
}
query.Parent = null; // Exclude Blocked Unrated Items
var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems);
if (blockedUnratedItems is not null)
{
query.BlockUnratedItems = blockedUnratedItems;
}
// At the user root an unfiltered, non-recursive request is a plain listing of the user's libraries // ExcludeLocationTypes
if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters) if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
{ {
// folder.GetItems applies user-access filtering via the InternalItemsQuery's User. query.IsVirtualItem = false;
}
if (locationTypes.Length > 0 && locationTypes.Length < 4)
{
query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
}
// Min official rating
if (!string.IsNullOrWhiteSpace(minOfficialRating))
{
query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
}
// Max official rating
if (!string.IsNullOrWhiteSpace(maxOfficialRating))
{
query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
}
// Artists
if (artists.Length != 0)
{
query.ArtistIds = artists.Select(i =>
{
try
{
return _libraryManager.GetArtist(i, new DtoOptions(false));
}
catch
{
return null;
}
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
// ExcludeArtistIds
if (excludeArtistIds.Length != 0)
{
query.ExcludeArtistIds = excludeArtistIds;
}
if (albumIds.Length != 0)
{
query.AlbumIds = albumIds;
}
// Albums
if (albums.Length != 0)
{
query.AlbumIds = albums.SelectMany(i =>
{
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 });
}).ToArray();
}
// Studios
if (studios.Length != 0)
{
query.StudioIds = studios.Select(i =>
{
try
{
return _libraryManager.GetStudio(i);
}
catch
{
return null;
}
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
// Apply default sorting if none requested
if (query.OrderBy.Count == 0)
{
// Albums by artist
if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
{
query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) };
}
}
query.Parent = null;
result = folder.GetItems(query); result = folder.GetItems(query);
if (searchResultScores is not null && searchResultScores.Count > 0)
{
var orderedItems = result.Items
.OrderByDescending(item => searchResultScores.GetValueOrDefault(item.Id, 0f))
.ThenBy(item => item.SortName)
.ToArray();
var totalCount = orderedItems.Length;
if (startIndex.HasValue && startIndex.Value > 0)
{
orderedItems = orderedItems.Skip(startIndex.Value).ToArray();
}
if (limit.HasValue)
{
orderedItems = orderedItems.Take(limit.Value).ToArray();
}
return new QueryResult<BaseItemDto>(
startIndex,
totalCount,
_dtoService.GetBaseItemDtos(orderedItems, dtoOptions, user));
}
} }
else else
{ {
@@ -970,7 +909,7 @@ public class ItemsController : BaseJellyfinApiController
var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
{ {
OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)], OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
IsResumable = true, IsResumable = true,
StartIndex = startIndex, StartIndex = startIndex,
Limit = limit, Limit = limit,
@@ -980,7 +919,6 @@ public class ItemsController : BaseJellyfinApiController
MediaTypes = mediaTypes, MediaTypes = mediaTypes,
IsVirtualItem = false, IsVirtualItem = false,
CollapseBoxSetItems = false, CollapseBoxSetItems = false,
IncludeOwnedItems = true,
EnableTotalRecordCount = enableTotalRecordCount, EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds, AncestorIds = ancestorIds,
IncludeItemTypes = includeItemTypes, IncludeItemTypes = includeItemTypes,

View File

@@ -1002,7 +1002,9 @@ public class LiveTvController : BaseJellyfinApiController
{ {
if (!string.IsNullOrEmpty(pw)) if (!string.IsNullOrEmpty(pw))
{ {
listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw))); // TODO: remove ToLower when Convert.ToHexString supports lowercase
// Schedules Direct requires the hex to be lowercase
listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
} }
return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);

View File

@@ -213,7 +213,7 @@ public class MediaInfoController : BaseJellyfinApiController
Request.HttpContext.GetNormalizedRemoteIP()); Request.HttpContext.GetNormalizedRemoteIP());
} }
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id); _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
} }
if (autoOpenLiveStream.Value) if (autoOpenLiveStream.Value)

View File

@@ -3,7 +3,6 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders; using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
@@ -30,7 +29,7 @@ namespace Jellyfin.Api.Controllers;
[Authorize] [Authorize]
public class SearchController : BaseJellyfinApiController public class SearchController : BaseJellyfinApiController
{ {
private readonly ISearchManager _searchManager; private readonly ISearchEngine _searchEngine;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService; private readonly IDtoService _dtoService;
private readonly IImageProcessor _imageProcessor; private readonly IImageProcessor _imageProcessor;
@@ -38,17 +37,17 @@ public class SearchController : BaseJellyfinApiController
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SearchController"/> class. /// Initializes a new instance of the <see cref="SearchController"/> class.
/// </summary> /// </summary>
/// <param name="searchManager">Instance of <see cref="ISearchManager"/> interface.</param> /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param>
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
/// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param> /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
public SearchController( public SearchController(
ISearchManager searchManager, ISearchEngine searchEngine,
ILibraryManager libraryManager, ILibraryManager libraryManager,
IDtoService dtoService, IDtoService dtoService,
IImageProcessor imageProcessor) IImageProcessor imageProcessor)
{ {
_searchManager = searchManager; _searchEngine = searchEngine;
_libraryManager = libraryManager; _libraryManager = libraryManager;
_dtoService = dtoService; _dtoService = dtoService;
_imageProcessor = imageProcessor; _imageProcessor = imageProcessor;
@@ -80,7 +79,7 @@ public class SearchController : BaseJellyfinApiController
[HttpGet] [HttpGet]
[Description("Gets search hints based on a search term")] [Description("Gets search hints based on a search term")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<SearchHintResult>> GetSearchHints( public ActionResult<SearchHintResult> GetSearchHints(
[FromQuery] int? startIndex, [FromQuery] int? startIndex,
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
@@ -101,7 +100,7 @@ public class SearchController : BaseJellyfinApiController
[FromQuery] bool includeArtists = true) [FromQuery] bool includeArtists = true)
{ {
userId = RequestHelpers.GetUserId(User, userId); userId = RequestHelpers.GetUserId(User, userId);
var result = await _searchManager.GetSearchHintsAsync(new SearchQuery var result = _searchEngine.GetSearchHints(new SearchQuery
{ {
Limit = limit, Limit = limit,
SearchTerm = searchTerm, SearchTerm = searchTerm,
@@ -122,7 +121,7 @@ public class SearchController : BaseJellyfinApiController
IsNews = isNews, IsNews = isNews,
IsSeries = isSeries, IsSeries = isSeries,
IsSports = isSports IsSports = isSports
}).ConfigureAwait(false); });
return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount);
} }

View File

@@ -122,7 +122,6 @@ public class TrailersController : BaseJellyfinApiController
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
[HttpGet] [HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[Obsolete("Use GetItems with includeItemTypes=Trailer instead.")]
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers( public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers(
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] string? maxOfficialRating, [FromQuery] string? maxOfficialRating,

View File

@@ -232,7 +232,7 @@ public class TvShowsController : BaseJellyfinApiController
if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
{ {
var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value, user); var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value);
if (item is not Season seasonItem) if (item is not Season seasonItem)
{ {
return NotFound("No season exists with Id " + seasonId); return NotFound("No season exists with Id " + seasonId);
@@ -242,7 +242,7 @@ public class TvShowsController : BaseJellyfinApiController
} }
else if (season.HasValue) // Season number was supplied. Get episodes by season number else if (season.HasValue) // Season number was supplied. Get episodes by season number
{ {
var series = _libraryManager.GetItemById<Series>(seriesId, user); var series = _libraryManager.GetItemById<Series>(seriesId);
if (series is null) if (series is null)
{ {
return NotFound("Series not found"); return NotFound("Series not found");
@@ -258,7 +258,7 @@ public class TvShowsController : BaseJellyfinApiController
} }
else // No season number or season id was supplied. Returning all episodes. else // No season number or season id was supplied. Returning all episodes.
{ {
if (_libraryManager.GetItemById<BaseItem>(seriesId, user) is not Series series) if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series)
{ {
return NotFound("Series not found"); return NotFound("Series not found");
} }

View File

@@ -163,7 +163,7 @@ public class UniversalAudioController : BaseJellyfinApiController
Request.HttpContext.GetNormalizedRemoteIP()); Request.HttpContext.GetNormalizedRemoteIP());
} }
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id); _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
foreach (var source in info.MediaSources) foreach (var source in info.MediaSources)
{ {

View File

@@ -429,8 +429,14 @@ public class UserLibraryController : BaseJellyfinApiController
} }
var dtoOptions = new DtoOptions(); var dtoOptions = new DtoOptions();
if (item is IHasTrailers hasTrailers)
{
var trailers = hasTrailers.LocalTrailers;
return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable());
}
return Ok(item.GetExtras([ExtraType.Trailer], user) return Ok(item.GetExtras()
.Where(e => e.ExtraType == ExtraType.Trailer)
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
} }
@@ -481,7 +487,7 @@ public class UserLibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions(); var dtoOptions = new DtoOptions();
return Ok(item return Ok(item
.GetExtras(user) .GetExtras()
.Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value)) .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value))
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
} }

View File

@@ -116,7 +116,7 @@ public class VideosController : BaseJellyfinApiController
BaseItemDto[] items; BaseItemDto[] items;
if (item is Video video) if (item is Video video)
{ {
items = video.GetAdditionalParts(user) items = video.GetAdditionalParts()
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
.ToArray(); .ToArray();
} }

View File

@@ -351,20 +351,11 @@ public class MediaInfoHelper
/// </summary> /// </summary>
/// <param name="result">Playback info response.</param> /// <param name="result">Playback info response.</param>
/// <param name="maxBitrate">Max bitrate.</param> /// <param name="maxBitrate">Max bitrate.</param>
/// <param name="preferredItemId">The id of the queried item, whose own media source must stay the default.</param> public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default)
{ {
var originalList = result.MediaSources.ToList(); var originalList = result.MediaSources.ToList();
// The queried item's source carries the user's resume state for that version, so it must stay the result.MediaSources = result.MediaSources.OrderBy(i =>
// default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version.
var preferredId = preferredItemId.IsEmpty()
? null
: preferredItemId.ToString("N", CultureInfo.InvariantCulture);
result.MediaSources = result.MediaSources
.OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
.ThenBy(i =>
{ {
// Nothing beats direct playing a file // Nothing beats direct playing a file
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)

View File

@@ -12,7 +12,6 @@ using Jellyfin.Database.Implementations;
using Jellyfin.Server.Implementations.StorageHelpers; using Jellyfin.Server.Implementations.StorageHelpers;
using Jellyfin.Server.Implementations.SystemBackupService; using Jellyfin.Server.Implementations.SystemBackupService;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.SystemBackupService; using MediaBrowser.Controller.SystemBackupService;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -34,7 +33,6 @@ public class BackupService : IBackupService
private readonly IServerApplicationPaths _applicationPaths; private readonly IServerApplicationPaths _applicationPaths;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly IHostApplicationLifetime _hostApplicationLifetime; private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILibraryManager _libraryManager;
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General) private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
{ {
AllowTrailingCommas = true, AllowTrailingCommas = true,
@@ -52,15 +50,13 @@ public class BackupService : IBackupService
/// <param name="applicationPaths">The application paths.</param> /// <param name="applicationPaths">The application paths.</param>
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param> /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
/// <param name="applicationLifetime">The SystemManager.</param> /// <param name="applicationLifetime">The SystemManager.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public BackupService( public BackupService(
ILogger<BackupService> logger, ILogger<BackupService> logger,
IDbContextFactory<JellyfinDbContext> dbProvider, IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost applicationHost, IServerApplicationHost applicationHost,
IServerApplicationPaths applicationPaths, IServerApplicationPaths applicationPaths,
IJellyfinDatabaseProvider jellyfinDatabaseProvider, IJellyfinDatabaseProvider jellyfinDatabaseProvider,
IHostApplicationLifetime applicationLifetime, IHostApplicationLifetime applicationLifetime)
ILibraryManager libraryManager)
{ {
_logger = logger; _logger = logger;
_dbProvider = dbProvider; _dbProvider = dbProvider;
@@ -68,7 +64,6 @@ public class BackupService : IBackupService
_applicationPaths = applicationPaths; _applicationPaths = applicationPaths;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider; _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
_hostApplicationLifetime = applicationLifetime; _hostApplicationLifetime = applicationLifetime;
_libraryManager = libraryManager;
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -268,14 +263,6 @@ public class BackupService : IBackupService
/// <inheritdoc/> /// <inheritdoc/>
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions) public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
{ {
// Creating a backup runs a database optimization and reads the entire database under a transaction, both of
// which heavily contend with an active library scan and could capture an inconsistent database state.
if (_libraryManager.IsScanRunning)
{
_logger.LogWarning("Cannot create a backup while a library scan is running.");
throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished.");
}
var manifest = new BackupManifest() var manifest = new BackupManifest()
{ {
DateCreated = DateTime.UtcNow, DateCreated = DateTime.UtcNow,

View File

@@ -586,7 +586,8 @@ public sealed partial class BaseItemRepository
if (filter.AlbumIds.Length > 0) if (filter.AlbumIds.Length > 0)
{ {
baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value)); var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id);
baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album));
} }
if (filter.ExcludeArtistIds.Length > 0) if (filter.ExcludeArtistIds.Length > 0)
@@ -952,17 +953,24 @@ public sealed partial class BaseItemRepository
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
{ {
baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds); var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray();
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f)));
} }
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
{ {
baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId); // Allow setting a null or empty value to get all items that have the specified provider set.
} var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray();
if (includeAny.Length > 0)
{
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId)));
}
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray();
{ if (includeSelected.Length > 0)
baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds); {
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f)));
}
} }
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)

View File

@@ -65,13 +65,8 @@ public class ItemPersistenceService : IItemPersistenceService
descendantIds.Add(id); descendantIds.Add(id);
} }
// Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
// single parameter (json_each) rather than one SQL variable per id, which would otherwise
// overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
var ownerIds = descendantIds.ToArray();
var extraIds = context.BaseItems var extraIds = context.BaseItems
.Where(e => e.OwnerId.HasValue) .Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value))
.WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
.Select(e => e.Id) .Select(e => e.Id)
.ToArray(); .ToArray();
@@ -562,11 +557,9 @@ public class ItemPersistenceService : IItemPersistenceService
} }
} }
// Deduplicate; local (file-based) relationships take priority over linked (user-merged)
// ones, matching the LinkedChildren migration.
newLinkedChildren = newLinkedChildren newLinkedChildren = newLinkedChildren
.GroupBy(c => c.ChildId) .GroupBy(c => c.ChildId)
.Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First()) .Select(g => g.Last())
.ToList(); .ToList();
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList(); var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();

View File

@@ -4,7 +4,6 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncKeyedLock; using AsyncKeyedLock;
@@ -29,7 +28,7 @@ namespace Jellyfin.Server.Implementations.Trickplay;
/// <summary> /// <summary>
/// ITrickplayManager implementation. /// ITrickplayManager implementation.
/// </summary> /// </summary>
public partial class TrickplayManager : ITrickplayManager public class TrickplayManager : ITrickplayManager
{ {
private readonly ILogger<TrickplayManager> _logger; private readonly ILogger<TrickplayManager> _logger;
private readonly IMediaEncoder _mediaEncoder; private readonly IMediaEncoder _mediaEncoder;
@@ -136,147 +135,6 @@ public partial class TrickplayManager : ITrickplayManager
} }
} }
private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationToken)
{
var options = _config.Configuration.TrickplayOptions;
var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
// Remove DB rows whose on-disk folder no longer exists in either possible location.
// Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration.
var orphanedWidths = new List<int>();
foreach (var (width, info) in existing)
{
cancellationToken.ThrowIfCancellationRequested();
var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false);
var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true);
if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir))
{
orphanedWidths.Add(width);
}
}
if (orphanedWidths.Count > 0)
{
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await dbContext.TrickplayInfos
.Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
foreach (var width in orphanedWidths)
{
_logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path);
existing.Remove(width);
}
}
var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
if (!Directory.Exists(trickplayDirectory))
{
return;
}
foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories())
{
cancellationToken.ThrowIfCancellationRequested();
var match = TrickplaySubdirRegex().Match(subdir.Name);
if (!match.Success)
{
continue;
}
var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
if (existing.ContainsKey(width))
{
continue;
}
var tiles = subdir.GetFiles("*.jpg")
.OrderBy(t => t.Name, StringComparer.Ordinal)
.ToArray();
if (tiles.Length == 0)
{
continue;
}
// The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real
// thumbnail count cannot be read from tile dimensions. Instead, bound the count from
// the tile count and per-tile capacity, then pick an interval consistent with the
// video runtime - snapping to the server's configured interval when it fits.
var thumbsPerTile = tileWidth * tileHeight;
var maxThumbs = tiles.Length * thumbsPerTile;
var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1;
int interval;
int thumbnailCount;
if (video.RunTimeTicks is long ticks)
{
var runtimeMs = ticks / TimeSpan.TicksPerMillisecond;
var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs));
var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs));
if (options.Interval >= minInterval && options.Interval <= maxInterval)
{
interval = options.Interval;
}
else
{
var midpoint = (minInterval + maxInterval) / 2.0;
var snapped = (long)Math.Round(midpoint / 1000d) * 1000L;
interval = (int)Math.Clamp(snapped, minInterval, maxInterval);
}
thumbnailCount = Math.Clamp(
(int)Math.Round(runtimeMs / (double)interval),
minThumbs,
maxThumbs);
}
else
{
interval = Math.Max(1000, options.Interval);
thumbnailCount = maxThumbs;
}
var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName);
var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight));
var info = new TrickplayInfo
{
ItemId = video.Id,
Width = width,
Interval = interval,
TileWidth = tileWidth,
TileHeight = tileHeight,
ThumbnailCount = thumbnailCount,
Height = thumbPxH,
Bandwidth = 0,
};
foreach (var tile in tiles)
{
var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m));
info.Bandwidth = Math.Max(info.Bandwidth, bitrate);
}
await SaveTrickplayInfo(info).ConfigureAwait(false);
_logger.LogInformation(
"Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interval}ms interval) for {Path}",
width,
tileWidth,
tileHeight,
thumbnailCount,
interval,
video.Path);
}
}
/// <inheritdoc /> /// <inheritdoc />
public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken) public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{ {
@@ -286,27 +144,11 @@ public partial class TrickplayManager : ITrickplayManager
return; return;
} }
var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
// Catalog any existing trickplay folders on disk before any prune/generate. This picks up
// user-placed files even when their (width, tile dims) don't match the server's configured values.
if (!replace)
{
await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false);
}
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
// When extraction is disabled and files live next to media, treat them as user-managed:
// discovery above already catalogued whatever is on disk, leave it alone.
if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia)
{
return;
}
if (!libraryOptions.EnableTrickplayImageExtraction || replace) if (!libraryOptions.EnableTrickplayImageExtraction || replace)
{ {
// Prune existing data // Prune existing data
@@ -846,19 +688,6 @@ public partial class TrickplayManager : ITrickplayManager
return Path.Combine(path, subdirectory); return Path.Combine(path, subdirectory);
} }
[GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")]
private static partial Regex TrickplaySubdirRegex();
private static bool HasTrickplayTiles(string directory)
{
if (!Directory.Exists(directory))
{
return false;
}
return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any();
}
private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width) private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
{ {
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);

View File

@@ -51,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly LockHelper _userLock = new(); private readonly AsyncKeyedLocker<Guid> _userLock = new();
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class. /// Initializes a new instance of the <see cref="UserManager"/> class.
@@ -214,58 +214,7 @@ namespace Jellyfin.Server.Implementations.Users
{ {
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{ {
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await UpdateUserInternalAsync(user).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
// TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead.
var dbUser = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == user.Id)
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(user.Id));
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
dbUser.Permissions.Clear();
foreach (var permission in user.Permissions)
{
dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
}
dbUser.Preferences.Clear();
foreach (var preference in user.Preferences)
{
dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
}
dbUser.AccessSchedules.Clear();
foreach (var accessSchedule in user.AccessSchedules)
{
dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id));
}
if (user.ProfileImage is null)
{
if (dbUser.ProfileImage is not null)
{
dbContext.Remove(dbUser.ProfileImage);
dbUser.ProfileImage = null;
}
}
else if (dbUser.ProfileImage is null)
{
dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path)
{
LastModified = user.ProfileImage.LastModified
};
}
else
{
dbUser.ProfileImage.Path = user.ProfileImage.Path;
dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified;
}
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
} }
} }
@@ -504,14 +453,12 @@ namespace Jellyfin.Server.Implementations.Users
var user = GetUserByName(username); var user = GetUserByName(username);
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
{ {
using var dbContext = _dbProvider.CreateDbContext();
// Reload the user now that we hold the lock so the RowVersion is current. // Reload the user now that we hold the lock so the RowVersion is current.
// GetUserByName uses AsNoTracking and the snapshot may be stale if another // GetUserByName uses AsNoTracking and the snapshot may be stale if another
// write (e.g. a concurrent login) incremented RowVersion after our initial load. // write (e.g. a concurrent login) incremented RowVersion after our initial load.
if (user is not null) if (user is not null)
{ {
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user; user = GetUserById(user.Id) ?? user;
} }
var authResult = await AuthenticateLocalUser(username, password, user) var authResult = await AuthenticateLocalUser(username, password, user)
@@ -519,13 +466,6 @@ namespace Jellyfin.Server.Implementations.Users
var authenticationProvider = authResult.AuthenticationProvider; var authenticationProvider = authResult.AuthenticationProvider;
success = authResult.Success; success = authResult.Success;
if (success && user is not null)
{
// refresh the user if the auth provider might have updated it in the auth method.
// this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
}
if (user is null) if (user is null)
{ {
string updatedUsername = authResult.Username; string updatedUsername = authResult.Username;
@@ -539,16 +479,11 @@ namespace Jellyfin.Server.Implementations.Users
// Search the database for the user again // Search the database for the user again
// the authentication provider might have created it // the authentication provider might have created it
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons user = GetUserByName(username);
user = await UserQuery(dbContext)
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
{ {
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
user = await UserQuery(dbContext)
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
} }
} }
} }
@@ -559,10 +494,8 @@ namespace Jellyfin.Server.Implementations.Users
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
{ {
await dbContext.Users user.AuthenticationProviderId = providerId;
.Where(e => e.Id == user.Id) await UpdateUserInternalAsync(user).ConfigureAwait(false);
.ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
.ConfigureAwait(false);
} }
} }
@@ -609,42 +542,16 @@ namespace Jellyfin.Server.Implementations.Users
{ {
if (isUserSession) if (isUserSession)
{ {
var date = DateTime.UtcNow; user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
await dbContext.Users
.Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e
.SetProperty(f => f.LastActivityDate, date)
.SetProperty(f => f.LastLoginDate, date))
.ConfigureAwait(false);
} }
await dbContext.Users user.InvalidLoginAttemptCount = 0;
.Where(e => e.Id == user.Id) await UpdateUserInternalAsync(user).ConfigureAwait(false);
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0))
.ConfigureAwait(false);
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
} }
else else
{ {
user.InvalidLoginAttemptCount++; await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
await dbContext.SaveChangesAsync()
.ConfigureAwait(false);
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
_logger.LogWarning(
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
user.Username,
user.InvalidLoginAttemptCount);
}
await dbContext.Users
.Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1))
.ConfigureAwait(false);
_logger.LogInformation( _logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).", "Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username, user.Username,
@@ -1019,6 +926,32 @@ namespace Jellyfin.Server.Implementations.Users
} }
} }
private async Task IncrementInvalidLoginAttemptCount(User user)
{
user.InvalidLoginAttemptCount++;
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
_logger.LogWarning(
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
user.Username,
user.InvalidLoginAttemptCount);
}
await UpdateUserInternalAsync(user).ConfigureAwait(false);
}
private async Task UpdateUserInternalAsync(User user)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
}
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{ {
dbContext.Users.Attach(user); dbContext.Users.Attach(user);
@@ -1044,70 +977,5 @@ namespace Jellyfin.Server.Implementations.Users
_userLock.Dispose(); _userLock.Dispose();
} }
} }
internal sealed class LockHelper : IDisposable
{
private readonly AsyncKeyedLocker<Guid> _userLock = new();
private bool _disposed;
public static AsyncLocal<int> IsNestedLock { get; set; } = new();
public bool ShouldLock()
{
return IsNestedLock.Value == 0;
}
public ValueTask<IDisposable> LockAsync(Guid key)
{
ThrowIfDisposed();
var isNested = LockHelper.IsNestedLock.Value != 0;
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1;
if (isNested)
{
return new ValueTask<IDisposable>(new LockHandle { Parent = null });
}
return AcquireLockAsync(key);
}
private async ValueTask<IDisposable> AcquireLockAsync(Guid key)
{
var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false);
return new LockHandle { Parent = lockHandle };
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_userLock.Dispose();
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private sealed class LockHandle : IDisposable
{
public required IDisposable? Parent { get; init; }
public void Dispose()
{
Parent?.Dispose();
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1;
if (LockHelper.IsNestedLock.Value < 0)
{
throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible.");
}
}
}
}
} }
} }

View File

@@ -215,11 +215,8 @@ internal class JellyfinMigrationService
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
var migrationIndex = 0;
foreach (var item in migrations) foreach (var item in migrations)
{ {
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length));
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try try
{ {

View File

@@ -12,22 +12,22 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines; namespace Jellyfin.Server.Migrations.Routines;
/// <summary> /// <summary>
/// Migration to refresh CleanName values for all library items and CleanValue values for all item values. /// Migration to refresh CleanName values for all library items.
/// </summary> /// </summary>
[JellyfinMigration("2026-06-10T12:00:00", nameof(RefreshCleanNamesAndValues))] [JellyfinMigration("2025-10-08T12:00:00", nameof(RefreshCleanNames))]
[JellyfinMigrationBackup(JellyfinDb = true)] [JellyfinMigrationBackup(JellyfinDb = true)]
public class RefreshCleanNamesAndValues : IAsyncMigrationRoutine public class RefreshCleanNames : IAsyncMigrationRoutine
{ {
private readonly IStartupLogger<RefreshCleanNamesAndValues> _logger; private readonly IStartupLogger<RefreshCleanNames> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="RefreshCleanNamesAndValues"/> class. /// Initializes a new instance of the <see cref="RefreshCleanNames"/> class.
/// </summary> /// </summary>
/// <param name="logger">The logger.</param> /// <param name="logger">The logger.</param>
/// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
public RefreshCleanNamesAndValues( public RefreshCleanNames(
IStartupLogger<RefreshCleanNamesAndValues> logger, IStartupLogger<RefreshCleanNames> logger,
IDbContextFactory<JellyfinDbContext> dbProvider) IDbContextFactory<JellyfinDbContext> dbProvider)
{ {
_logger = logger; _logger = logger;
@@ -36,12 +36,6 @@ public class RefreshCleanNamesAndValues : IAsyncMigrationRoutine
/// <inheritdoc /> /// <inheritdoc />
public async Task PerformAsync(CancellationToken cancellationToken) public async Task PerformAsync(CancellationToken cancellationToken)
{
await RefreshCleanNamesAsync(cancellationToken).ConfigureAwait(false);
await RefreshCleanValuesAsync(cancellationToken).ConfigureAwait(false);
}
private async Task RefreshCleanNamesAsync(CancellationToken cancellationToken)
{ {
const int Limit = 10000; const int Limit = 10000;
int itemCount = 0; int itemCount = 0;
@@ -105,69 +99,4 @@ public class RefreshCleanNamesAndValues : IAsyncMigrationRoutine
records, records,
sw.Elapsed); sw.Elapsed);
} }
private async Task RefreshCleanValuesAsync(CancellationToken cancellationToken)
{
const int Limit = 10000;
int itemCount = 0;
var sw = Stopwatch.StartNew();
using var context = _dbProvider.CreateDbContext();
var records = context.ItemValues.Count(b => !string.IsNullOrEmpty(b.Value));
_logger.LogInformation("Refreshing CleanValue for {Count} item values", records);
var processedInPartition = 0;
await foreach (var item in context.ItemValues
.Where(b => !string.IsNullOrEmpty(b.Value))
.OrderBy(e => e.ItemValueId)
.WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed))
.PartitionEagerAsync(Limit, cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
try
{
var newCleanValue = string.IsNullOrWhiteSpace(item.Value) ? string.Empty : item.Value.GetCleanValue();
if (!string.Equals(newCleanValue, item.CleanValue, StringComparison.Ordinal))
{
_logger.LogDebug(
"Updating CleanValue for item value {Id}: '{OldValue}' -> '{NewValue}'",
item.ItemValueId,
item.CleanValue,
newCleanValue);
item.CleanValue = newCleanValue;
itemCount++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update CleanValue for item value {Id} ({Value})", item.ItemValueId, item.Value);
}
processedInPartition++;
if (processedInPartition >= Limit)
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
// Clear tracked entities to avoid memory growth across partitions
context.ChangeTracker.Clear();
processedInPartition = 0;
}
}
// Save any remaining changes after the loop
if (processedInPartition > 0)
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
context.ChangeTracker.Clear();
}
_logger.LogInformation(
"Refreshed CleanValue for {UpdatedCount} out of {TotalCount} item values in {Time}",
itemCount,
records,
sw.Elapsed);
}
} }

View File

@@ -223,35 +223,6 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList(); toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
// Drop linked (user-merged) entries that point at items the parent owns (local
// file-based alternates or extras). These stem from legacy data that merged an
// owned item onto its own primary and would wrongly mark server-merged groups
// as user-merged (splittable).
var linkedChildIds = toInsert
.Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion)
.Select(lc => lc.ChildId)
.Distinct()
.ToList();
if (linkedChildIds.Count > 0)
{
var ownerIdByChildId = context.BaseItems
.WhereOneOrMany(linkedChildIds, b => b.Id)
.Where(b => b.OwnerId.HasValue)
.Select(b => new { b.Id, b.OwnerId })
.ToDictionary(b => b.Id, b => b.OwnerId!.Value);
var removedCount = toInsert.RemoveAll(lc =>
lc.ChildType == LinkedChildType.LinkedAlternateVersion
&& ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId)
&& ownerId.Equals(lc.ParentId));
if (removedCount > 0)
{
_logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned by their parent.", removedCount);
}
}
context.LinkedChildren.AddRange(toInsert); context.LinkedChildren.AddRange(toInsert);
context.SaveChanges(); context.SaveChanges();

View File

@@ -76,36 +76,25 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count); _logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
// Resolve items for metadata path cleanup, then delete in batches so we never issue one // Batch-resolve items for metadata path cleanup, then delete all at once
// massive delete transaction and progress stays visible on large libraries. var itemsToDelete = new List<BaseItem>();
_logger.LogInformation("Deleting {Count} orphaned extras...", orphanedItemIds.Count); foreach (var itemId in orphanedItemIds)
const int deleteBatchSize = 500;
var deletedSoFar = 0;
for (var offset = 0; offset < orphanedItemIds.Count; offset += deleteBatchSize)
{ {
cancellationToken.ThrowIfCancellationRequested(); itemsToDelete.Add(BaseItemMapper.DeserializeBaseItem(
new Database.Implementations.Entities.BaseItemEntity()
var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset)); {
var itemsToDelete = batch Id = itemId.Id,
.Select(itemId => BaseItemMapper.DeserializeBaseItem( Path = itemId.Path,
new Database.Implementations.Entities.BaseItemEntity() Type = itemId.Type
{ },
Id = itemId.Id, _logger,
Path = itemId.Path, null,
Type = itemId.Type true)!);
},
_logger,
null,
true)!)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
deletedSoFar += batch.Count;
_logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count);
} }
_logger.LogInformation("Successfully removed {Count} orphaned extras", orphanedItemIds.Count); _libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
_logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count);
} }
} }
} }

View File

@@ -136,38 +136,19 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
if (allIdsToDelete.Count > 0) if (allIdsToDelete.Count > 0)
{ {
_logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count); // Batch-resolve items for metadata path cleanup, then delete all at once
var itemsToDelete = allIdsToDelete
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
// Delete in batches so progress is visible (item resolution and deletion can take a // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
// long time on large libraries) and so we never issue one massive delete transaction. var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
const int deleteBatchSize = 500; var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
var deletedSoFar = 0; if (unresolvedIds.Count > 0)
for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize)
{ {
cancellationToken.ThrowIfCancellationRequested(); _persistenceService.DeleteItem(unresolvedIds);
var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset));
// Resolve items for metadata path cleanup, then delete this batch
var itemsToDelete = batchIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
deletedSoFar += batchIds.Count;
_logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count);
} }
} }

View File

@@ -182,35 +182,23 @@ public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind. // %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
// Fall back to the persistence service for any items the LibraryManager can't resolve. // Fall back to the persistence service for any items the LibraryManager can't resolve.
// Delete in batches so we never issue one massive delete transaction and progress stays visible. var itemsToDelete = idsToDelete
_logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count); .Select(id => _libraryManager.GetItemById(id))
const int deleteBatchSize = 500; .Where(item => item is not null)
var deletedSoFar = 0; .ToList();
for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) if (itemsToDelete.Count > 0)
{ {
cancellationToken.ThrowIfCancellationRequested(); _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
var itemsToDelete = batchIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
deletedSoFar += batchIds.Count;
_logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
} }
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
_logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count);
} }
} }
} }

View File

@@ -184,35 +184,23 @@ public class MergeDuplicatePeople : IAsyncMigrationRoutine
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
// %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind. // %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
// Delete in batches so we never issue one massive delete transaction and progress stays visible. var itemsToDelete = idsToDelete
_logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count); .Select(id => _libraryManager.GetItemById(id))
const int deleteBatchSize = 500; .Where(item => item is not null)
var deletedSoFar = 0; .ToList();
for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) if (itemsToDelete.Count > 0)
{ {
cancellationToken.ThrowIfCancellationRequested(); _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
var itemsToDelete = batchIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
deletedSoFar += batchIds.Count;
_logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
} }
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
_logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count);
} }
private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken) private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)

View File

@@ -1,182 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that
/// no longer exist in the <c>BaseItems</c> table. The database side is cleaned up synchronously by
/// <c>IItemPersistenceService.DeleteItem</c>, so the leftover orphans live on the filesystem.
/// </summary>
[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))]
[JellyfinMigrationBackup(JellyfinDb = true)]
public class CleanupOrphanedExternalData : IAsyncMigrationRoutine
{
private const int ProgressLogStep = 500;
private readonly IStartupLogger<CleanupOrphanedExternalData> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly IApplicationPaths _appPaths;
private readonly IServerApplicationPaths _serverPaths;
/// <summary>
/// Initializes a new instance of the <see cref="CleanupOrphanedExternalData"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="appPaths">The application paths.</param>
/// <param name="serverPaths">The server application paths.</param>
public CleanupOrphanedExternalData(
IStartupLogger<CleanupOrphanedExternalData> logger,
IDbContextFactory<JellyfinDbContext> dbContextFactory,
IApplicationPaths appPaths,
IServerApplicationPaths serverPaths)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
_appPaths = appPaths;
_serverPaths = serverPaths;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false);
CleanupGuidIndexedRoot(
"attachment",
Path.Combine(_appPaths.DataPath, "attachments"),
knownIds,
deleteSubPath: null,
cancellationToken);
CleanupGuidIndexedRoot(
"subtitle",
Path.Combine(_appPaths.DataPath, "subtitles"),
knownIds,
deleteSubPath: null,
cancellationToken);
CleanupGuidIndexedRoot(
"trickplay",
_appPaths.TrickplayPath,
knownIds,
deleteSubPath: null,
cancellationToken);
CleanupGuidIndexedRoot(
"chapter image",
Path.Combine(_serverPaths.InternalMetadataPath, "library"),
knownIds,
deleteSubPath: "chapters",
cancellationToken);
}
private async Task<HashSet<Guid>> LoadKnownItemIdsAsync(CancellationToken cancellationToken)
{
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
var ids = await context.BaseItems
.AsNoTracking()
.Select(b => b.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. ids];
}
}
private void CleanupGuidIndexedRoot(
string label,
string root,
HashSet<Guid> knownIds,
string? deleteSubPath,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
{
_logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root);
return;
}
_logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root);
var scanned = 0;
var removed = 0;
foreach (var prefixDir in Directory.EnumerateDirectories(root))
{
cancellationToken.ThrowIfCancellationRequested();
var prefixName = Path.GetFileName(prefixDir);
if (prefixName.Length != 2)
{
continue;
}
foreach (var guidDir in Directory.EnumerateDirectories(prefixDir))
{
cancellationToken.ThrowIfCancellationRequested();
scanned++;
if (scanned % ProgressLogStep == 0)
{
_logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed);
}
var leafName = Path.GetFileName(guidDir);
if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id))
{
continue;
}
if (knownIds.Contains(id))
{
continue;
}
var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath);
if (deleteSubPath is not null && !Directory.Exists(target))
{
continue;
}
if (TryDelete(target))
{
removed++;
}
}
}
_logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed);
}
private bool TryDelete(string dir)
{
try
{
Directory.Delete(dir, recursive: true);
return true;
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir);
}
return false;
}
}

View File

@@ -133,12 +133,10 @@ namespace Jellyfin.Server
} }
} }
SetupServer.ReportActivity(StartupActivity.CheckingStorage);
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check")); StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
StartupHelpers.PerformStaticInitialization(); StartupHelpers.PerformStaticInitialization();
SetupServer.ReportActivity(StartupActivity.Initializing);
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false); await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
do do
@@ -197,7 +195,6 @@ namespace Jellyfin.Server
if (!string.IsNullOrWhiteSpace(_restoreFromBackup)) if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
{ {
SetupServer.ReportActivity(StartupActivity.RestoringBackup);
await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false); await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
_restoreFromBackup = null; _restoreFromBackup = null;
_restartOnShutdown = true; _restartOnShutdown = true;
@@ -205,13 +202,9 @@ namespace Jellyfin.Server
} }
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider); var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
SetupServer.ReportActivity(StartupActivity.PreparingMigrations);
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
// "Preparing migrations" carries through the DB read; per-migration progress is reported
// as "Running migration X of Y" from inside the step once the pending set is known.
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
SetupServer.ReportActivity(StartupActivity.InitializingServices);
await appHost.InitializeServices(startupConfig).ConfigureAwait(false); await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
_appHost = appHost; _appHost = appHost;

View File

@@ -14,6 +14,7 @@ using Jellyfin.Server.Extensions;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.System; using MediaBrowser.Model.System;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
@@ -24,6 +25,9 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
using Morestachio;
using Morestachio.Framework.IO.SingleStream;
using Morestachio.Rendering;
using Serilog; using Serilog;
using ILogger = Microsoft.Extensions.Logging.ILogger; using ILogger = Microsoft.Extensions.Logging.ILogger;
@@ -40,8 +44,7 @@ public sealed class SetupServer : IDisposable
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly IConfiguration _startupConfiguration; private readonly IConfiguration _startupConfiguration;
private readonly ServerConfigurationManager _configurationManager; private readonly ServerConfigurationManager _configurationManager;
private static volatile string _currentActivity = StartupActivity.Starting; private IRenderer? _startupUiRenderer;
private StartupUiRenderer? _startupUiRenderer;
private IHost? _startupServer; private IHost? _startupServer;
private bool _disposed; private bool _disposed;
private bool _isUnhealthy; private bool _isUnhealthy;
@@ -73,12 +76,6 @@ public sealed class SetupServer : IDisposable
internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new(); internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
/// <summary>
/// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the
/// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details.
/// </summary>
internal static string CurrentActivity => _currentActivity;
/// <summary> /// <summary>
/// Gets a value indicating whether Startup server is currently running. /// Gets a value indicating whether Startup server is currently running.
/// </summary> /// </summary>
@@ -90,9 +87,64 @@ public sealed class SetupServer : IDisposable
/// <returns>A Task.</returns> /// <returns>A Task.</returns>
public async Task RunAsync() public async Task RunAsync()
{ {
ReportActivity(StartupActivity.Starting); var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
_startupUiRenderer = await StartupUiRenderer.CreateAsync( _startupUiRenderer = (await ParserOptionsBuilder.New()
Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); .WithTemplate(fileTemplate)
.WithFormatter(
(Version version, int arg) =>
{
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
return version.ToString(arg);
},
"ToString")
.WithFormatter(
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
{
if (children.Any())
{
var maxLevel = logEntry.LogLevel;
var stack = new Stack<StartupLogTopic>(children);
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
{
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
foreach (var child in logEntry.Children)
{
stack.Push(child);
}
}
return maxLevel;
}
return logEntry.LogLevel;
},
"FormatLogLevel")
.WithFormatter(
(LogLevel logLevel) =>
{
switch (logLevel)
{
case LogLevel.Trace:
case LogLevel.Debug:
case LogLevel.None:
return "success";
case LogLevel.Information:
return "info";
case LogLevel.Warning:
return "warn";
case LogLevel.Error:
return "danger";
case LogLevel.Critical:
return "danger-strong";
}
return string.Empty;
},
"ToString")
.BuildAndParseAsync()
.ConfigureAwait(false))
.CreateCompiledRenderer();
ThrowIfDisposed(); ThrowIfDisposed();
var retryAfterValue = TimeSpan.FromSeconds(5); var retryAfterValue = TimeSpan.FromSeconds(5);
@@ -205,14 +257,13 @@ public sealed class SetupServer : IDisposable
new Dictionary<string, object>() new Dictionary<string, object>()
{ {
{ "isInReportingMode", _isUnhealthy }, { "isInReportingMode", _isUnhealthy },
{ "currentActivity", CurrentActivity },
{ "retryValue", retryAfterValue }, { "retryValue", retryAfterValue },
{ "version", version }, { "version", version },
{ "logs", startupLogEntries }, { "logs", startupLogEntries },
{ "networkManagerReady", networkManager is not null }, { "networkManagerReady", networkManager is not null },
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) } { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
}, },
context.Response.BodyWriter.AsStream()) new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
.ConfigureAwait(false); .ConfigureAwait(false);
}); });
}); });
@@ -258,16 +309,6 @@ public sealed class SetupServer : IDisposable
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
} }
/// <summary>
/// Reports the current startup activity shown to all clients in the startup UI header.
/// Only pass generic, non-identifying text from <see cref="StartupActivity"/>.
/// </summary>
/// <param name="activity">A generic description such as <see cref="StartupActivity.PreparingMigrations"/>.</param>
internal static void ReportActivity(string activity)
{
_currentActivity = activity;
}
internal void SoftStop() internal void SoftStop()
{ {
_isUnhealthy = true; _isUnhealthy = true;

View File

@@ -1,41 +0,0 @@
using System.Globalization;
namespace Jellyfin.Server.ServerSetupApp;
/// <summary>
/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup.
/// These are shown in the always-visible header of the startup UI to <b>unauthenticated</b> clients, so every
/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.).
/// </summary>
public static class StartupActivity
{
/// <summary>The default state before any work has been reported.</summary>
public const string Starting = "Starting up";
/// <summary>Validating that the configured storage locations are usable.</summary>
public const string CheckingStorage = "Checking storage";
/// <summary>Bringing up the migration subsystem and running early startup checks.</summary>
public const string Initializing = "Initializing server";
/// <summary>Preparing the system for migrations (e.g. taking safety backups).</summary>
public const string PreparingMigrations = "Preparing migrations";
/// <summary>Restoring from a backup.</summary>
public const string RestoringBackup = "Restoring backup";
/// <summary>Bringing up core services and plugins.</summary>
public const string InitializingServices = "Initializing services";
/// <summary>Running the final startup tasks.</summary>
public const string FinishingStartup = "Finishing startup";
/// <summary>
/// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed.
/// </summary>
/// <param name="current">The 1-based index of the migration currently running.</param>
/// <param name="total">The total number of migrations in this batch.</param>
/// <returns>A generic progress description.</returns>
public static string Migration(int current, int total)
=> string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total);
}

View File

@@ -1,109 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
using Morestachio;
using Morestachio.Framework.IO.SingleStream;
using Morestachio.Rendering;
namespace Jellyfin.Server.ServerSetupApp;
/// <summary>
/// Compiles and renders the startup UI Morestachio template.
/// Shared by the live <see cref="SetupServer"/> and the standalone startup UI preview tool so both
/// exercise the exact same template and formatters.
/// </summary>
public sealed class StartupUiRenderer
{
private readonly IRenderer _renderer;
private StartupUiRenderer(IRenderer renderer)
{
_renderer = renderer;
}
/// <summary>
/// Compiles the startup UI template located at <paramref name="templatePath"/>.
/// </summary>
/// <param name="templatePath">The full path to the <c>index.mstemplate.html</c> template.</param>
/// <returns>A ready to use <see cref="StartupUiRenderer"/>.</returns>
public static async Task<StartupUiRenderer> CreateAsync(string templatePath)
{
var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false);
var renderer = (await ParserOptionsBuilder.New()
.WithTemplate(fileTemplate)
.WithFormatter(
(Version version, int arg) =>
{
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
return version.ToString(arg);
},
"ToString")
.WithFormatter(
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
{
if (children.Any())
{
var maxLevel = logEntry.LogLevel;
var stack = new Stack<StartupLogTopic>(children);
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
{
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
foreach (var child in logEntry.Children)
{
stack.Push(child);
}
}
return maxLevel;
}
return logEntry.LogLevel;
},
"FormatLogLevel")
.WithFormatter(
(LogLevel logLevel) =>
{
switch (logLevel)
{
case LogLevel.Trace:
case LogLevel.Debug:
case LogLevel.None:
return "success";
case LogLevel.Information:
return "info";
case LogLevel.Warning:
return "warn";
case LogLevel.Error:
return "danger";
case LogLevel.Critical:
return "danger-strong";
}
return string.Empty;
},
"ToString")
.BuildAndParseAsync()
.ConfigureAwait(false))
.CreateCompiledRenderer();
return new StartupUiRenderer(renderer);
}
/// <summary>
/// Renders the template with the provided model into the target stream.
/// </summary>
/// <param name="model">The values made available to the template.</param>
/// <param name="output">The stream the rendered HTML is written to.</param>
/// <returns>A Task.</returns>
public Task RenderAsync(IDictionary<string, object> model, Stream output)
{
return _renderer.RenderAsync(
model,
new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions));
}
}

File diff suppressed because one or more lines are too long

View File

@@ -2718,7 +2718,7 @@ namespace MediaBrowser.Controller.Entities
public IReadOnlyList<BaseItem> GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy) public IReadOnlyList<BaseItem> GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
{ {
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray(); return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray();
} }
public IReadOnlyList<BaseItem> GetThemeVideos(User user = null) public IReadOnlyList<BaseItem> GetThemeVideos(User user = null)
@@ -2728,17 +2728,16 @@ namespace MediaBrowser.Controller.Entities
public IReadOnlyList<BaseItem> GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy) public IReadOnlyList<BaseItem> GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
{ {
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray(); return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
} }
/// <summary> /// <summary>
/// Get all extras associated with this item, sorted by <see cref="SortName"/>. /// Get all extras associated with this item, sorted by <see cref="SortName"/>.
/// </summary> /// </summary>
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
/// <returns>An enumerable containing the items.</returns> /// <returns>An enumerable containing the items.</returns>
public IEnumerable<BaseItem> GetExtras(User user = null) public IEnumerable<BaseItem> GetExtras()
{ {
return LibraryManager.GetItemList(new InternalItemsQuery(user) return LibraryManager.GetItemList(new InternalItemsQuery()
{ {
OwnerIds = [Id], OwnerIds = [Id],
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
@@ -2749,11 +2748,10 @@ namespace MediaBrowser.Controller.Entities
/// Get all extras with specific types that are associated with this item. /// Get all extras with specific types that are associated with this item.
/// </summary> /// </summary>
/// <param name="extraTypes">The types of extras to retrieve.</param> /// <param name="extraTypes">The types of extras to retrieve.</param>
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
/// <returns>An enumerable containing the extras.</returns> /// <returns>An enumerable containing the extras.</returns>
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes, User user = null) public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes)
{ {
return LibraryManager.GetItemList(new InternalItemsQuery(user) return LibraryManager.GetItemList(new InternalItemsQuery()
{ {
OwnerIds = [Id], OwnerIds = [Id],
ExtraTypes = extraTypes.ToArray(), ExtraTypes = extraTypes.ToArray(),

View File

@@ -906,10 +906,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = this; query.Parent = this;
} }
// BoxSets and Playlists can have per-user visibility (shares/open access) that is stored in the if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet)
// serialized item data and cannot be evaluated by the database query, so filter them in memory.
if (query.IncludeItemTypes.Length > 0
&& query.IncludeItemTypes.All(t => t == BaseItemKind.BoxSet || t == BaseItemKind.Playlist))
{ {
return QueryWithPostFiltering(query); return QueryWithPostFiltering(query);
} }
@@ -930,7 +927,7 @@ namespace MediaBrowser.Controller.Entities
if (user is not null) if (user is not null)
{ {
// needed for boxsets and playlists // needed for boxsets
itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User)); itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User));
} }

View File

@@ -72,102 +72,6 @@ namespace MediaBrowser.Controller.Entities
} }
} }
/// <summary>
/// Gets a value indicating whether the query carries any criteria that narrows the
/// result set, as opposed to user context, pagination, sorting or DTO options.
/// </summary>
public bool HasFilters =>
IncludeItemTypes.Length > 0
|| ExcludeItemTypes.Length > 0
|| Genres.Count > 0
|| GenreIds.Count > 0
|| Years.Length > 0
|| Tags.Length > 0
|| ExcludeTags.Length > 0
|| OfficialRatings.Length > 0
|| StudioIds.Length > 0
|| ArtistIds.Length > 0
|| AlbumArtistIds.Length > 0
|| ContributingArtistIds.Length > 0
|| ExcludeArtistIds.Length > 0
|| AlbumIds.Length > 0
|| PersonIds.Length > 0
|| PersonTypes.Length > 0
|| MediaTypes.Length > 0
|| VideoTypes.Length > 0
|| ImageTypes.Length > 0
|| SeriesStatuses.Length > 0
|| ItemIds.Length > 0
|| ExcludeItemIds.Length > 0
|| AudioLanguages.Count > 0
|| SubtitleLanguages.Count > 0
|| LinkedChildAncestorIds.Length > 0
|| AncestorIds.Length > 0
|| IsFavorite.HasValue
|| IsFavoriteOrLiked.HasValue
|| IsLiked.HasValue
|| IsPlayed.HasValue
|| IsResumable.HasValue
|| IsFolder.HasValue
|| IsMissing.HasValue
|| IsUnaired.HasValue
|| IsSpecialSeason.HasValue
|| Is3D.HasValue
|| IsHD.HasValue
|| Is4K.HasValue
|| IsLocked.HasValue
|| IsPlaceHolder.HasValue
|| IsMovie.HasValue
|| IsSports.HasValue
|| IsKids.HasValue
|| IsNews.HasValue
|| IsSeries.HasValue
|| IsAiring.HasValue
|| IsVirtualItem.HasValue
|| HasImdbId.HasValue
|| HasTmdbId.HasValue
|| HasTvdbId.HasValue
|| HasOverview.HasValue
|| HasOfficialRating.HasValue
|| HasParentalRating.HasValue
|| HasThemeSong.HasValue
|| HasThemeVideo.HasValue
|| HasSubtitles.HasValue
|| HasSpecialFeature.HasValue
|| HasTrailer.HasValue
|| HasChapterImages.HasValue
|| MinCriticRating.HasValue
|| MinCommunityRating.HasValue
|| MinParentalRating is not null
|| MinIndexNumber.HasValue
|| MinParentAndIndexNumber.HasValue
|| IndexNumber.HasValue
|| ParentIndexNumber.HasValue
|| AiredDuringSeason.HasValue
|| MinWidth.HasValue
|| MinHeight.HasValue
|| MaxWidth.HasValue
|| MaxHeight.HasValue
|| MinPremiereDate.HasValue
|| MaxPremiereDate.HasValue
|| MinStartDate.HasValue
|| MaxStartDate.HasValue
|| MinEndDate.HasValue
|| MaxEndDate.HasValue
|| MinDateCreated.HasValue
|| MinDateLastSaved.HasValue
|| MinDateLastSavedForUser.HasValue
|| AdjacentTo.HasValue
|| !string.IsNullOrEmpty(NameStartsWith)
|| !string.IsNullOrEmpty(NameStartsWithOrGreater)
|| !string.IsNullOrEmpty(NameLessThan)
|| !string.IsNullOrEmpty(NameContains)
|| !string.IsNullOrEmpty(MinSortName)
|| !string.IsNullOrEmpty(Name)
|| !string.IsNullOrEmpty(Person)
|| !string.IsNullOrEmpty(SearchTerm)
|| !string.IsNullOrEmpty(Path);
public bool Recursive { get; set; } public bool Recursive { get; set; }
public int? StartIndex { get; set; } public int? StartIndex { get; set; }

View File

@@ -15,7 +15,6 @@ namespace MediaBrowser.Controller.Entities
throw new ArgumentNullException(nameof(name)); throw new ArgumentNullException(nameof(name));
} }
name = name.Trim();
var current = item.Tags; var current = item.Tags;
if (!current.Contains(name, StringComparison.OrdinalIgnoreCase)) if (!current.Contains(name, StringComparison.OrdinalIgnoreCase))

View File

@@ -69,14 +69,8 @@ namespace MediaBrowser.Controller.Entities
protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
{ {
// The user root holds no items of its own - a plain listing returns the user's if (query.Recursive)
// views. But a request carrying any filter is a search across the libraries, so
// resolve it through the recursive query path even when Recursive wasn't set;
// otherwise the filters would be silently dropped. Recursive is set so the
// downstream query (ancestor/top-parent scoping) treats it as a recursive search.
if (query.Recursive || query.HasFilters)
{ {
query.Recursive = true;
return QueryRecursive(query); return QueryRecursive(query);
} }

View File

@@ -10,7 +10,6 @@ using System.Text.Json.Serialization;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.LiveTv;
@@ -391,13 +390,13 @@ namespace MediaBrowser.Controller.Entities
/// <summary> /// <summary>
/// Gets the additional parts. /// Gets the additional parts.
/// </summary> /// </summary>
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
/// <returns>IEnumerable{Video}.</returns> /// <returns>IEnumerable{Video}.</returns>
public IOrderedEnumerable<Video> GetAdditionalParts(User user = null) public IOrderedEnumerable<Video> GetAdditionalParts()
{ {
return GetAdditionalPartIds() return GetAdditionalPartIds()
.Select(i => LibraryManager.GetItemById<Video>(i, user)) .Select(i => LibraryManager.GetItemById(i))
.Where(i => i is not null) .Where(i => i is not null)
.OfType<Video>()
.OrderBy(i => i.SortName); .OrderBy(i => i.SortName);
} }

View File

@@ -16,11 +16,4 @@ public interface IExternalDataManager
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns> /// <returns>Task.</returns>
Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken); Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken);
/// <summary>
/// Deletes only the filesystem-side external item data (attachments, subtitles, trickplay, chapter images).
/// Use this when DB-side cleanup is already handled by another code path (e.g. <c>IItemPersistenceService.DeleteItem</c>).
/// </summary>
/// <param name="item">The item.</param>
void DeleteExternalItemFiles(BaseItem item);
} }

View File

@@ -1,20 +0,0 @@
using System.Collections.Generic;
using System.Threading;
namespace MediaBrowser.Controller.Library;
/// <summary>
/// Interface for external search providers that offer enhanced search capabilities.
/// </summary>
public interface IExternalSearchProvider : ISearchProvider
{
/// <summary>
/// Searches for items matching the query.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of search results with relevance scores.</returns>
new IAsyncEnumerable<SearchResult> SearchAsync(
SearchProviderQuery query,
CancellationToken cancellationToken);
}

View File

@@ -1,8 +0,0 @@
namespace MediaBrowser.Controller.Library;
/// <summary>
/// Marker interface for internal search providers that typically query the local database directly.
/// </summary>
public interface IInternalSearchProvider : ISearchProvider
{
}

View File

@@ -0,0 +1,18 @@
using MediaBrowser.Model.Querying;
using MediaBrowser.Model.Search;
namespace MediaBrowser.Controller.Library
{
/// <summary>
/// Interface ILibrarySearchEngine.
/// </summary>
public interface ISearchEngine
{
/// <summary>
/// Gets the search hints.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>Task{IEnumerable{SearchHintInfo}}.</returns>
QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query);
}
}

View File

@@ -1,48 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Querying;
using MediaBrowser.Model.Search;
namespace MediaBrowser.Controller.Library;
/// <summary>
/// Orchestrates search operations across registered search providers.
/// </summary>
public interface ISearchManager
{
/// <summary>
/// Searches for items and returns hints suitable for autocomplete/typeahead UI.
/// Results are ordered by relevance score from search providers.
/// </summary>
/// <param name="query">The search query including filters and pagination.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Paginated search hints with item metadata for display.</returns>
Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(
SearchQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets ranked search results from registered providers. Returns only item IDs and
/// relevance scores; callers are responsible for loading items and applying user-access filtering.
/// </summary>
/// <param name="query">The search provider query with type/media filters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Search results containing item IDs and relevance scores.</returns>
Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
SearchProviderQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Registers search providers discovered through dependency injection.
/// Called during application startup.
/// </summary>
/// <param name="providers">The search providers to register.</param>
void AddParts(IEnumerable<ISearchProvider> providers);
/// <summary>
/// Gets all registered search providers ordered by priority.
/// </summary>
/// <returns>The list of search providers including the SQL fallback provider.</returns>
IReadOnlyList<ISearchProvider> GetProviders();
}

View File

@@ -1,44 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Configuration;
namespace MediaBrowser.Controller.Library;
/// <summary>
/// Interface for search providers.
/// </summary>
public interface ISearchProvider
{
/// <summary>
/// Gets the name of the provider.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the type of the provider.
/// </summary>
MetadataPluginType Type { get; }
/// <summary>
/// Gets the priority of the provider. Lower values execute first.
/// </summary>
int Priority { get; }
/// <summary>
/// Searches for items matching the query.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Ranked list of candidate item IDs with scores.</returns>
Task<IReadOnlyList<SearchResult>> SearchAsync(
SearchProviderQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Determines whether this provider can handle the given query.
/// </summary>
/// <param name="query">The search query to evaluate.</param>
/// <returns>True if this provider can search for the query; otherwise, false.</returns>
bool CanSearch(SearchProviderQuery query);
}

View File

@@ -1,45 +0,0 @@
using System;
using Jellyfin.Data.Enums;
namespace MediaBrowser.Controller.Library;
/// <summary>
/// Query object for search providers.
/// </summary>
public class SearchProviderQuery
{
/// <summary>
/// Gets the search term.
/// </summary>
public required string SearchTerm { get; init; }
/// <summary>
/// Gets the user ID for user-specific searches.
/// </summary>
public Guid? UserId { get; init; }
/// <summary>
/// Gets the item types to include in the search.
/// </summary>
public BaseItemKind[] IncludeItemTypes { get; init; } = [];
/// <summary>
/// Gets the item types to exclude from the search.
/// </summary>
public BaseItemKind[] ExcludeItemTypes { get; init; } = [];
/// <summary>
/// Gets the media types to include in the search.
/// </summary>
public MediaType[] MediaTypes { get; init; } = [];
/// <summary>
/// Gets the maximum number of results to return.
/// </summary>
public int? Limit { get; init; }
/// <summary>
/// Gets the parent ID to scope the search.
/// </summary>
public Guid? ParentId { get; init; }
}

View File

@@ -1,60 +0,0 @@
using System;
namespace MediaBrowser.Controller.Library;
/// <summary>
/// Represents an item matched by a search query with its relevance score.
/// </summary>
public readonly struct SearchResult : IEquatable<SearchResult>
{
/// <summary>
/// Initializes a new instance of the <see cref="SearchResult"/> struct.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="score">The relevance score.</param>
public SearchResult(Guid itemId, float score)
{
ItemId = itemId;
Score = score;
}
/// <summary>
/// Gets the ID of the matching item.
/// </summary>
public Guid ItemId { get; init; }
/// <summary>
/// Gets the relevance score. Higher values indicate more relevant results.
/// </summary>
public float Score { get; init; }
/// <summary>
/// Compares two <see cref="SearchResult"/> instances for equality.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
/// <returns>True if the instances are equal; otherwise, false.</returns>
public static bool operator ==(SearchResult left, SearchResult right)
=> left.Equals(right);
/// <summary>
/// Compares two <see cref="SearchResult"/> instances for inequality.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
/// <returns>True if the instances are not equal; otherwise, false.</returns>
public static bool operator !=(SearchResult left, SearchResult right)
=> !left.Equals(right);
/// <inheritdoc/>
public override bool Equals(object? obj)
=> obj is SearchResult other && Equals(other);
/// <inheritdoc/>
public bool Equals(SearchResult other)
=> ItemId.Equals(other.ItemId) && Score.Equals(other.Score);
/// <inheritdoc/>
public override int GetHashCode()
=> HashCode.Combine(ItemId, Score);
}

View File

@@ -25,7 +25,6 @@ using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
@@ -445,13 +444,6 @@ namespace MediaBrowser.Controller.MediaEncoding
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG); || state.VideoStream.VideoRangeType == VideoRangeType.HLG);
} }
private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
{
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
return doDeintH264 || doDeintHevc;
}
private bool IsVideoStreamHevcRext(EncodingJobInfo state) private bool IsVideoStreamHevcRext(EncodingJobInfo state)
{ {
var videoStream = state.VideoStream; var videoStream = state.VideoStream;
@@ -2612,66 +2604,56 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs) public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs)
=> CanStreamCopyAudio(state, audioStream, supportedAudioCodecs, out _);
/// <summary>
/// Determines whether the given audio stream can be stream-copied and, regardless of the outcome,
/// reports the codec/parameter incompatibilities that would force a re-encode via <paramref name="failureReasons"/>.
/// </summary>
/// <param name="state">The encoding job state.</param>
/// <param name="audioStream">The source audio stream.</param>
/// <param name="supportedAudioCodecs">The audio codecs the target supports.</param>
/// <param name="failureReasons">The codec/parameter incompatibilities preventing a copy, or <c>0</c> if the stream is copy-compatible.</param>
/// <returns><c>true</c> if the audio stream can be stream-copied; otherwise, <c>false</c>.</returns>
public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs, out TranscodeReason failureReasons)
{ {
var request = state.BaseRequest; var request = state.BaseRequest;
// Policy-independent compatibility check, so the reasons are reported even when a policy gate is what ultimately prevents the copy. if (!request.AllowAudioStreamCopy)
failureReasons = GetAudioStreamCopyFailureReasons(state, audioStream, supportedAudioCodecs); {
return false;
return request.AllowAudioStreamCopy }
&& request.EnableAutoStreamCopy
&& failureReasons == 0;
}
private static TranscodeReason GetAudioStreamCopyFailureReasons(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs)
{
var request = state.BaseRequest;
TranscodeReason reasons = 0;
var maxBitDepth = state.GetRequestedAudioBitDepth(audioStream.Codec); var maxBitDepth = state.GetRequestedAudioBitDepth(audioStream.Codec);
if (maxBitDepth.HasValue if (maxBitDepth.HasValue
&& audioStream.BitDepth.HasValue && audioStream.BitDepth.HasValue
&& audioStream.BitDepth.Value > maxBitDepth.Value) && audioStream.BitDepth.Value > maxBitDepth.Value)
{ {
reasons |= TranscodeReason.AudioBitDepthNotSupported; return false;
} }
// Source and target codecs must match // Source and target codecs must match
if (string.IsNullOrEmpty(audioStream.Codec) if (string.IsNullOrEmpty(audioStream.Codec)
|| !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase)) || !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase))
{ {
reasons |= TranscodeReason.AudioCodecNotSupported; return false;
} }
// Channels must fall within requested value // Channels must fall within requested value
var channels = state.GetRequestedAudioChannels(audioStream.Codec); var channels = state.GetRequestedAudioChannels(audioStream.Codec);
if (channels.HasValue if (channels.HasValue)
&& (!audioStream.Channels.HasValue
|| audioStream.Channels.Value <= 0
|| audioStream.Channels.Value > channels.Value))
{ {
reasons |= TranscodeReason.AudioChannelsNotSupported; if (!audioStream.Channels.HasValue || audioStream.Channels.Value <= 0)
{
return false;
}
if (audioStream.Channels.Value > channels.Value)
{
return false;
}
} }
// Sample rate must fall within requested value // Sample rate must fall within requested value
if (request.AudioSampleRate.HasValue if (request.AudioSampleRate.HasValue)
&& (!audioStream.SampleRate.HasValue
|| audioStream.SampleRate.Value <= 0
|| audioStream.SampleRate.Value > request.AudioSampleRate.Value))
{ {
reasons |= TranscodeReason.AudioSampleRateNotSupported; if (!audioStream.SampleRate.HasValue || audioStream.SampleRate.Value <= 0)
{
return false;
}
if (audioStream.SampleRate.Value > request.AudioSampleRate.Value)
{
return false;
}
} }
// Audio bitrate must fall within requested value // Audio bitrate must fall within requested value
@@ -2679,10 +2661,10 @@ namespace MediaBrowser.Controller.MediaEncoding
&& audioStream.BitRate.HasValue && audioStream.BitRate.HasValue
&& audioStream.BitRate.Value > request.AudioBitRate.Value) && audioStream.BitRate.Value > request.AudioBitRate.Value)
{ {
reasons |= TranscodeReason.AudioBitrateNotSupported; return false;
} }
return reasons; return request.EnableAutoStreamCopy;
} }
public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec) public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec)
@@ -3868,7 +3850,9 @@ namespace MediaBrowser.Controller.MediaEncoding
var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase);
var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase); var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doToneMap = IsSwTonemapAvailable(state, options); var doToneMap = IsSwTonemapAvailable(state, options);
var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI; var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI;
@@ -4020,7 +4004,9 @@ namespace MediaBrowser.Controller.MediaEncoding
var isCuInCuOut = isNvDecoder && isNvencEncoder; var isCuInCuOut = isNvDecoder && isNvencEncoder;
var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30; var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30;
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doCuTonemap = IsHwTonemapAvailable(state, options); var doCuTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4229,7 +4215,9 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder; var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder;
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doOclTonemap = IsHwTonemapAvailable(state, options); var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4475,7 +4463,9 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder; var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doVppTonemap = IsIntelVppTonemapAvailable(state, options); var doVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options); var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVppTonemap || doOclTonemap; var doTonemap = doVppTonemap || doOclTonemap;
@@ -4767,10 +4757,12 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder; var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options); var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap; var doTonemap = doVaVppTonemap || doOclTonemap;
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH2645 = doDeintH264 || doDeintHevc;
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5096,10 +5088,12 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; var isVaInVaOut = isVaapiDecoder && isVaapiEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options); var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap; var doTonemap = doVaVppTonemap || doOclTonemap;
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH2645 = doDeintH264 || doDeintHevc;
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5331,8 +5325,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isSwEncoder = !isVaapiEncoder; var isSwEncoder = !isVaapiEncoder;
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVkTonemap = IsVulkanHwTonemapAvailable(state, options); var doVkTonemap = IsVulkanHwTonemapAvailable(state, options);
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH2645 = doDeintH264 || doDeintHevc;
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5569,7 +5565,9 @@ namespace MediaBrowser.Controller.MediaEncoding
var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965; var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965;
var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd; var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd;
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doOclTonemap = IsHwTonemapAvailable(state, options); var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -5800,7 +5798,9 @@ namespace MediaBrowser.Controller.MediaEncoding
var reqMaxH = state.BaseRequest.MaxHeight; var reqMaxH = state.BaseRequest.MaxHeight;
var threeDFormat = state.MediaSource.Video3DFormat; var threeDFormat = state.MediaSource.Video3DFormat;
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options); var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options);
var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options); var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options);
var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface); var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface);
@@ -5999,7 +5999,9 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase) && (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase)
|| vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase)); || vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase));
var doDeintH2645 = IsDeinterlaceAvailable(state); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doOclTonemap = IsHwTonemapAvailable(state, options); var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -6263,21 +6265,12 @@ namespace MediaBrowser.Controller.MediaEncoding
overlayFilters?.RemoveAll(string.IsNullOrEmpty); overlayFilters?.RemoveAll(string.IsNullOrEmpty);
var framerate = GetFramerateParam(state); var framerate = GetFramerateParam(state);
if (mainFilters is not null && framerate.HasValue) if (framerate.HasValue)
{ {
var doDeintH2645 = IsDeinterlaceAvailable(state); mainFilters.Insert(0, string.Format(
var fpsFilter = string.Format(CultureInfo.InvariantCulture, "fps={0}", framerate.Value); CultureInfo.InvariantCulture,
"fps={0}",
// For filter chain containing the deinterlace filter, framerate.Value));
// place the fps filter at the end to preserve temporal info.
if (doDeintH2645)
{
mainFilters.Add(fpsFilter);
}
else
{
mainFilters.Insert(0, fpsFilter);
}
} }
var mainStr = string.Empty; var mainStr = string.Empty;
@@ -7228,9 +7221,8 @@ namespace MediaBrowser.Controller.MediaEncoding
&& !IsCopyCodec(state.OutputVideoCodec) && !IsCopyCodec(state.OutputVideoCodec)
&& options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio; && options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio;
TranscodeReason audioCopyFailureReasons = 0;
if (state.AudioStream is not null if (state.AudioStream is not null
&& CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs, out audioCopyFailureReasons) && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs)
&& !preventHlsAudioCopy) && !preventHlsAudioCopy)
{ {
state.OutputAudioCodec = "copy"; state.OutputAudioCodec = "copy";
@@ -7244,13 +7236,6 @@ namespace MediaBrowser.Controller.MediaEncoding
{ {
state.OutputAudioCodec = "copy"; state.OutputAudioCodec = "copy";
} }
else if (state.AudioStream is not null && !IsCopyCodec(state.OutputAudioCodec))
{
// Audio is actually being re-encoded although the playback determination may have considered the source copyable.
// Only carry the primary "cannot be passed through" cause - the codec mismatch.
// Bitrate/channels/sample-rate/bit-depth copy refusals are consequences of the chosen transcode target.
state.AddTranscodeReason(audioCopyFailureReasons & TranscodeReason.AudioCodecNotSupported);
}
} }
} }
@@ -7870,14 +7855,13 @@ namespace MediaBrowser.Controller.MediaEncoding
audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate); audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate);
} }
var sampleRate = state.OutputAudioSampleRate; if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
if (sampleRate.HasValue)
{ {
var sampleRateValue = sampleRate.Value; // opus only supports specific sampling rates
if (string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) var sampleRate = state.OutputAudioSampleRate;
if (sampleRate.HasValue)
{ {
// opus only supports specific sampling rates var sampleRateValue = sampleRate.Value switch
sampleRateValue = sampleRate.Value switch
{ {
<= 8000 => 8000, <= 8000 => 8000,
<= 12000 => 12000, <= 12000 => 12000,
@@ -7885,9 +7869,9 @@ namespace MediaBrowser.Controller.MediaEncoding
<= 24000 => 24000, <= 24000 => 24000,
_ => 48000 _ => 48000
}; };
}
audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
}
} }
// Copy the movflags from GetProgressiveVideoFullCommandLine // Copy the movflags from GetProgressiveVideoFullCommandLine

View File

@@ -515,15 +515,6 @@ namespace MediaBrowser.Controller.MediaEncoding
public int HlsListSize => 0; public int HlsListSize => 0;
/// <summary>
/// Adds the specified reason(s) to <see cref="TranscodeReasons"/>.
/// </summary>
/// <param name="reason">The transcode reason(s) to add.</param>
public void AddTranscodeReason(TranscodeReason reason)
{
_transcodeReasons = TranscodeReasons | reason;
}
private int? GetMediaStreamCount(MediaStreamType type, int limit) private int? GetMediaStreamCount(MediaStreamType type, int limit)
{ {
var count = MediaSource.GetStreamCount(type); var count = MediaSource.GetStreamCount(type);

View File

@@ -1,10 +1,8 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncKeyedLock; using AsyncKeyedLock;
@@ -104,10 +102,13 @@ namespace MediaBrowser.MediaEncoding.Attachments
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{ {
await ExtractAllAttachmentsIndividuallyInternal( foreach (var attachment in mediaSource.MediaAttachments)
inputFile, {
mediaSource, if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
cancellationToken).ConfigureAwait(false); {
await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false);
}
}
} }
else else
{ {
@@ -118,140 +119,6 @@ namespace MediaBrowser.MediaEncoding.Attachments
} }
} }
private async Task ExtractAllAttachmentsIndividuallyInternal(
string inputFile,
MediaSourceInfo mediaSource,
CancellationToken cancellationToken)
{
var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
ArgumentException.ThrowIfNullOrEmpty(inputPath);
var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
if (outputFolder is null)
{
_logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile);
return;
}
using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
{
Directory.CreateDirectory(outputFolder);
var dumpArgs = new StringBuilder();
var missingPaths = new List<string>();
foreach (var attachment in mediaSource.MediaAttachments)
{
if (string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var indexName = attachment.Index.ToString(CultureInfo.InvariantCulture);
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, attachment.FileName ?? indexName)
?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
if (File.Exists(attachmentPath))
{
continue;
}
dumpArgs.AppendFormat(
CultureInfo.InvariantCulture,
"-dump_attachment:{0} \"{1}\" ",
attachment.Index,
EncodingUtils.NormalizePath(attachmentPath));
missingPaths.Add(attachmentPath);
}
if (missingPaths.Count == 0)
{
// Skip extraction if all files already exist
return;
}
var hasVideoOrAudioStream = mediaSource.MediaStreams
.Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
var processArgs = string.Format(
CultureInfo.InvariantCulture,
"{0}{1} -i {2} {3}",
dumpArgs,
inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
inputPath,
hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
int exitCode;
using (var process = new Process
{
StartInfo = new ProcessStartInfo
{
Arguments = processArgs,
FileName = _mediaEncoder.EncoderPath,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
EnableRaisingEvents = true
})
{
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
process.Start();
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
}
}
var failed = false;
if (exitCode != 0 && (hasVideoOrAudioStream || exitCode != 1))
{
failed = true;
foreach (var path in missingPaths)
{
if (!File.Exists(path))
{
continue;
}
try
{
_fileSystem.DeleteFile(path);
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted attachment {Path}", path);
}
}
}
if (!failed && missingPaths.Exists(p => !File.Exists(p)))
{
failed = true;
}
if (failed)
{
_logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder);
throw new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder));
}
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder);
}
}
private async Task ExtractAllAttachmentsInternal( private async Task ExtractAllAttachmentsInternal(
string inputFile, string inputFile,
MediaSourceInfo mediaSource, MediaSourceInfo mediaSource,

View File

@@ -1,11 +1,9 @@
#pragma warning disable CA1031 #pragma warning disable CA1031
using System; using System;
using System.Buffers;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Text;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace MediaBrowser.MediaEncoding.Encoder; namespace MediaBrowser.MediaEncoding.Encoder;
@@ -14,43 +12,43 @@ namespace MediaBrowser.MediaEncoding.Encoder;
/// Helper class for Apple platform specific operations. /// Helper class for Apple platform specific operations.
/// </summary> /// </summary>
[SupportedOSPlatform("macos")] [SupportedOSPlatform("macos")]
public static partial class ApplePlatformHelper public static class ApplePlatformHelper
{ {
private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"]; private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"];
internal static string GetSysctlValue(string name) private static string GetSysctlValue(ReadOnlySpan<byte> name)
{ {
nuint length = 0; IntPtr length = IntPtr.Zero;
// Get length of the value // Get length of the value
int osStatus = sysctlbyname(name, Span<byte>.Empty, ref length, IntPtr.Zero, 0); int osStatus = SysctlByName(name, IntPtr.Zero, ref length, IntPtr.Zero, 0);
if (osStatus != 0 || length == 0)
if (osStatus != 0)
{ {
throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}"); throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
} }
byte[] buffer = ArrayPool<byte>.Shared.Rent((int)length); IntPtr buffer = Marshal.AllocHGlobal(length.ToInt32());
try try
{ {
osStatus = sysctlbyname(name, buffer.AsSpan()[..(int)length], ref length, IntPtr.Zero, 0); osStatus = SysctlByName(name, buffer, ref length, IntPtr.Zero, 0);
if (osStatus != 0) if (osStatus != 0)
{ {
throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}"); throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
} }
if (length < 1) return Marshal.PtrToStringAnsi(buffer) ?? string.Empty;
{
return string.Empty;
}
ReadOnlySpan<byte> data = buffer.AsSpan()[..(int)(length - 1)];
return Encoding.UTF8.GetString(data);
} }
finally finally
{ {
ArrayPool<byte>.Shared.Return(buffer); Marshal.FreeHGlobal(buffer);
} }
} }
private static int SysctlByName(ReadOnlySpan<byte> name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen)
{
return NativeMethods.SysctlByName(name.ToArray(), oldp, ref oldlenp, newp, newlen);
}
/// <summary> /// <summary>
/// Check if the current system has hardware acceleration for AV1 decoding. /// Check if the current system has hardware acceleration for AV1 decoding.
/// </summary> /// </summary>
@@ -65,7 +63,7 @@ public static partial class ApplePlatformHelper
try try
{ {
string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"); string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"u8);
return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase)); return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase));
} }
catch (NotSupportedException e) catch (NotSupportedException e)
@@ -80,7 +78,10 @@ public static partial class ApplePlatformHelper
return false; return false;
} }
[LibraryImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)] private static class NativeMethods
[DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] {
internal static partial int sysctlbyname([MarshalAs(UnmanagedType.LPStr)] string name, Span<byte> oldp, ref nuint oldlenp, IntPtr newp, nuint newlen); [DllImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
internal static extern int SysctlByName(byte[] name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen);
}
} }

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