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
236 changed files with 1686 additions and 9099 deletions

View File

@@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"dotnet-ef": { "dotnet-ef": {
"version": "10.0.10", "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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.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@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 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@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.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

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }} if: ${{ contains(github.repository, 'jellyfin/') }}
steps: steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
repo-token: ${{ secrets.JF_BOT_TOKEN }} repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true ascending: true

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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.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

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }} if: ${{ contains(github.repository, 'jellyfin/') }}
steps: steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
repo-token: ${{ secrets.JF_BOT_TOKEN }} repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true ascending: true

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.10" /> <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" /> <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.10" /> <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

@@ -125,7 +125,7 @@ namespace Emby.Naming.TV
result.Success = true; result.Success = true;
} }
} }
else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date)) else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date))
{ {
result.Year = date.Year; result.Year = date.Year;
result.Month = date.Month; result.Month = date.Month;

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

@@ -1,3 +1,5 @@
#pragma warning disable CS1591
using System; using System;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
@@ -8,9 +10,6 @@ using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Devices namespace Emby.Server.Implementations.Devices
{ {
/// <summary>
/// Provides the persistent unique identifier of this server installation.
/// </summary>
public class DeviceId public class DeviceId
{ {
private readonly IApplicationPaths _appPaths; private readonly IApplicationPaths _appPaths;
@@ -19,20 +18,12 @@ namespace Emby.Server.Implementations.Devices
private string? _id; private string? _id;
/// <summary>
/// Initializes a new instance of the <see cref="DeviceId"/> class.
/// </summary>
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param>
public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger) public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
{ {
_appPaths = appPaths; _appPaths = appPaths;
_logger = logger; _logger = logger;
} }
/// <summary>
/// Gets the device id, loading it from disk or generating and persisting a new one if none exists.
/// </summary>
public string Value => _id ??= GetDeviceId(); public string Value => _id ??= GetDeviceId();
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt"); private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");

View File

@@ -71,8 +71,6 @@ namespace Emby.Server.Implementations.Dto
{ {
BaseItemKind.Person, [ BaseItemKind.Person, [
BaseItemKind.Audio, BaseItemKind.Audio,
BaseItemKind.AudioBook,
BaseItemKind.Book,
BaseItemKind.Episode, BaseItemKind.Episode,
BaseItemKind.Movie, BaseItemKind.Movie,
BaseItemKind.LiveTvProgram, BaseItemKind.LiveTvProgram,
@@ -169,13 +167,9 @@ namespace Emby.Server.Implementations.Dto
// Batch-fetch user data for all items // Batch-fetch user data for all items
Dictionary<Guid, UserItemData>? userDataBatch = null; Dictionary<Guid, UserItemData>? userDataBatch = null;
IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null;
if (user is not null && options.EnableUserData) if (user is not null && options.EnableUserData)
{ {
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user); userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
// For items with alternate versions, the most recently played version drives resume.
resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user);
} }
// Pre-compute collection folders once to avoid N+1 queries in CanDelete // Pre-compute collection folders once to avoid N+1 queries in CanDelete
@@ -254,8 +248,7 @@ namespace Emby.Server.Implementations.Dto
allCollectionFolders, allCollectionFolders,
childCountBatch, childCountBatch,
playedCountBatch, playedCountBatch,
artistsBatch, artistsBatch);
resumeDataBatch?.GetValueOrDefault(item.Id));
if (item is LiveTvChannel tvChannel) if (item is LiveTvChannel tvChannel)
{ {
@@ -316,8 +309,7 @@ namespace Emby.Server.Implementations.Dto
List<Folder>? allCollectionFolders = null, List<Folder>? allCollectionFolders = null,
Dictionary<Guid, int>? childCountBatch = null, Dictionary<Guid, int>? childCountBatch = null,
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
VersionResumeData? resumeData = null)
{ {
var dto = new BaseItemDto var dto = new BaseItemDto
{ {
@@ -361,8 +353,7 @@ namespace Emby.Server.Implementations.Dto
options, options,
userData, userData,
childCountBatch, childCountBatch,
playedCountBatch, playedCountBatch);
resumeData);
} }
if (item is IHasMediaSources if (item is IHasMediaSources
@@ -378,7 +369,7 @@ namespace Emby.Server.Implementations.Dto
AttachStudios(dto, item); AttachStudios(dto, item);
} }
AttachBasicFields(dto, item, owner, options, artistsBatch, user); AttachBasicFields(dto, item, owner, options, artistsBatch);
if (options.ContainsField(ItemFields.CanDelete)) if (options.ContainsField(ItemFields.CanDelete))
{ {
@@ -547,8 +538,7 @@ namespace Emby.Server.Implementations.Dto
DtoOptions options, DtoOptions options,
UserItemData? userData = null, UserItemData? userData = null,
Dictionary<Guid, int>? childCountBatch = null, Dictionary<Guid, int>? childCountBatch = null,
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
VersionResumeData? resumeData = null)
{ {
if (item.IsFolder) if (item.IsFolder)
{ {
@@ -610,9 +600,6 @@ namespace Emby.Server.Implementations.Dto
// Use pre-fetched user data // Use pre-fetched user data
dto.UserData = GetUserItemDataDto(userData, item.Id); dto.UserData = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options); item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
// For items with alternate versions, the most recently played version drives resume.
resumeData?.ApplyTo(dto.UserData);
} }
else else
{ {
@@ -956,8 +943,7 @@ namespace Emby.Server.Implementations.Dto
/// <param name="owner">The owner.</param> /// <param name="owner">The owner.</param>
/// <param name="options">The options.</param> /// <param name="options">The options.</param>
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param> /// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
/// <param name="user">The user, for per-user values such as the accessible media source count.</param> private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null)
{ {
if (options.ContainsField(ItemFields.DateCreated)) if (options.ContainsField(ItemFields.DateCreated))
{ {
@@ -1271,12 +1257,7 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.MediaSourceCount)) if (options.ContainsField(ItemFields.MediaSourceCount))
{ {
// Match the per-user filtering of the media sources: versions the user cannot var mediaSourceCount = video.MediaSourceCount;
// access are not selectable, so they must not count towards the badge either.
var mediaSourceCount = user is null
|| (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
? video.MediaSourceCount
: video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
if (mediaSourceCount != 1) if (mediaSourceCount != 1)
{ {
dto.MediaSourceCount = mediaSourceCount; dto.MediaSourceCount = mediaSourceCount;
@@ -1385,22 +1366,38 @@ namespace Emby.Server.Implementations.Dto
} }
} }
if (options.GetImageLimit(ImageType.Primary) > 0) if (options.PreferEpisodeParentPoster)
{ {
var episodeSeason = episode.Season; var episodeSeason = episode.Season;
var seasonPrimaryTag = episodeSeason is not null var seasonPrimaryTag = episodeSeason is not null
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary) ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
: null; : null;
BaseItem? posterParent = null;
if (seasonPrimaryTag is not null) if (seasonPrimaryTag is not null)
{ {
dto.ParentPrimaryImageItemId = episodeSeason!.Id; dto.ParentPrimaryImageItemId = episodeSeason!.Id;
dto.ParentPrimaryImageTag = seasonPrimaryTag; dto.ParentPrimaryImageTag = seasonPrimaryTag;
posterParent = episodeSeason;
} }
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null) else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
{ {
dto.ParentPrimaryImageItemId = episodeSeries.Id; dto.ParentPrimaryImageItemId = episodeSeries.Id;
dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag; dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
posterParent = episodeSeries;
}
if (posterParent is not null)
{
if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag))
{
// Only drop the episode's own primary blurhash; keep the poster parent's.
dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag);
}
dto.SeriesPrimaryImageTag = null;
dto.PrimaryImageAspectRatio = null;
AttachPrimaryImageAspectRatio(dto, posterParent);
} }
} }
@@ -1542,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

@@ -127,12 +127,8 @@ namespace Emby.Server.Implementations.HttpServer
{ {
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
} }
catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException) catch (WebSocketException ex)
{ {
// ObjectDisposedException/OperationCanceledException: the socket was torn
// down underneath us (e.g. by the keep-alive watchdog after the connection
// was declared lost). Fall through so Closed is still raised and the
// session can release this connection.
_logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message); _logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
break; break;
} }

View File

@@ -691,7 +691,7 @@ namespace Emby.Server.Implementations.IO
} }
catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException) catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException)
{ {
_logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message); _logger.LogError(ex, "Failed to enumerate path {Path}", path);
return Enumerable.Empty<string>(); return Enumerable.Empty<string>();
} }
} }

View File

@@ -5,7 +5,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
@@ -15,6 +14,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Images namespace Emby.Server.Implementations.Images
{ {
@@ -28,7 +28,38 @@ namespace Emby.Server.Implementations.Images
{ {
var view = (CollectionFolder)item; var view = (CollectionFolder)item;
var viewType = view.CollectionType; var viewType = view.CollectionType;
var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
BaseItemKind[] includeItemTypes;
switch (viewType)
{
case CollectionType.movies:
includeItemTypes = new[] { BaseItemKind.Movie };
break;
case CollectionType.tvshows:
includeItemTypes = new[] { BaseItemKind.Series };
break;
case CollectionType.music:
includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
break;
case CollectionType.musicvideos:
includeItemTypes = new[] { BaseItemKind.MusicVideo };
break;
case CollectionType.books:
includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
break;
case CollectionType.boxsets:
includeItemTypes = new[] { BaseItemKind.BoxSet };
break;
case CollectionType.homevideos:
case CollectionType.photos:
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo };
break;
default:
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series };
break;
}
var recursive = viewType != CollectionType.playlists; var recursive = viewType != CollectionType.playlists;
return view.GetItemList(new InternalItemsQuery return view.GetItemList(new InternalItemsQuery
@@ -36,9 +67,12 @@ namespace Emby.Server.Implementations.Images
CollapseBoxSetItems = false, CollapseBoxSetItems = false,
Recursive = recursive, Recursive = recursive,
DtoOptions = new DtoOptions(false), DtoOptions = new DtoOptions(false),
ImageTypes = [ImageType.Primary], ImageTypes = new[] { ImageType.Primary },
Limit = 8, Limit = 8,
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)], OrderBy = new[]
{
(ItemSortBy.Random, SortOrder.Ascending)
},
IncludeItemTypes = includeItemTypes IncludeItemTypes = includeItemTypes
}); });
} }

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,11 +229,7 @@ namespace Emby.Server.Implementations.Library
list.Add(source); list.Add(source);
} }
var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId) return SortMediaSources(list).ToArray();
? topSourceId
: item.Id;
return SortMediaSources(list, preferredId).ToArray();
} }
/// <inheritdoc />> /// <inheritdoc />>
@@ -390,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);
@@ -410,59 +400,6 @@ namespace Emby.Server.Implementations.Library
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing);
} }
} }
sources = SetAlternateVersionResumeStates(item, sources, user);
}
return sources;
}
/// <summary>
/// When the queried item is a primary, moves the most recently played version to the front so
/// that resuming without an explicit source selection plays the version that was last watched.
/// A directly queried alternate version keeps its own source first. Per-user playback position
/// is not surfaced on the source itself; it is carried by each version's own UserData.
/// </summary>
/// <param name="item">The queried item.</param>
/// <param name="sources">The item's media sources.</param>
/// <param name="user">The user.</param>
/// <returns>The media sources, reordered when a version drives resume.</returns>
private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user)
{
// For a video, multiple sources means alternate versions.
if (item is not Video video || sources.Count < 2)
{
return sources;
}
var versions = video.GetAllVersions();
if (versions.Count < 2)
{
return sources;
}
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase);
foreach (var version in versions)
{
if (userDataByVersion.TryGetValue(version.Id, out var data))
{
dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data;
}
}
// Reorder only for a resumable (in-progress) version;
// a completed version has no position to resume, so it must not be pulled to the front here.
var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
sources,
source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null,
data => data.PlaybackPositionTicks > 0);
if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource))
{
var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource };
reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource)));
return reordered;
} }
return sources; return sources;
@@ -603,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

@@ -1,7 +1,6 @@
#nullable disable #nullable disable
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
@@ -47,16 +46,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
} }
// It's a directory-based playlist if the directory contains a playlist file // It's a directory-based playlist if the directory contains a playlist file
IEnumerable<string> filePaths; var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
try
{
filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
}
catch (IOException)
{
return null;
}
if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase))) if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
{ {
return new Playlist return new Playlist

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

@@ -53,7 +53,6 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IItemQueryHelpers _queryHelpers; private readonly IItemQueryHelpers _queryHelpers;
private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly ILibraryManager _libraryManager;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class. /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
@@ -61,17 +60,14 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
/// <param name="dbProvider">The database context factory.</param> /// <param name="dbProvider">The database context factory.</param>
/// <param name="queryHelpers">The shared query helpers.</param> /// <param name="queryHelpers">The shared query helpers.</param>
/// <param name="serverConfigurationManager">The server configuration manager.</param> /// <param name="serverConfigurationManager">The server configuration manager.</param>
/// <param name="libraryManager">The library manager.</param>
public MovieSimilarItemsProvider( public MovieSimilarItemsProvider(
IDbContextFactory<JellyfinDbContext> dbProvider, IDbContextFactory<JellyfinDbContext> dbProvider,
IItemQueryHelpers queryHelpers, IItemQueryHelpers queryHelpers,
IServerConfigurationManager serverConfigurationManager, IServerConfigurationManager serverConfigurationManager)
ILibraryManager libraryManager)
{ {
_dbProvider = dbProvider; _dbProvider = dbProvider;
_queryHelpers = queryHelpers; _queryHelpers = queryHelpers;
_serverConfigurationManager = serverConfigurationManager; _serverConfigurationManager = serverConfigurationManager;
_libraryManager = libraryManager;
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -160,11 +156,6 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
IsPlayed = false IsPlayed = false
}; };
if (query.User is not null)
{
_libraryManager.ConfigureUserAccess(filter, query.User);
}
_queryHelpers.PrepareFilterQuery(filter); _queryHelpers.PrepareFilterQuery(filter);
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);

View File

@@ -247,103 +247,6 @@ namespace Emby.Server.Implementations.Library
return result; return result;
} }
/// <inheritdoc />
public VersionResumeData? GetResumeUserData(User user, BaseItem item)
{
return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id);
}
/// <inheritdoc />
public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user)
{
ArgumentNullException.ThrowIfNull(user);
var result = new Dictionary<Guid, VersionResumeData>();
// Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data.
// Linked alternates are already known in memory; only the local-alternate existence check
// would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions),
// so collect those ids and resolve them all in a single query below.
List<Video>? candidates = null;
List<Guid>? localProbeIds = null;
foreach (var item in items)
{
if (item is not Video video || video.PrimaryVersionId.HasValue)
{
continue;
}
(candidates ??= []).Add(video);
if (video.LinkedAlternateVersions.Length == 0)
{
(localProbeIds ??= []).Add(video.Id);
}
}
if (candidates is null)
{
return result;
}
HashSet<Guid>? withLocalAlternates = null;
if (localProbeIds is not null)
{
using var dbContext = _repository.CreateDbContext();
withLocalAlternates = dbContext.LinkedChildren
.Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion
&& localProbeIds.Contains(lc.ParentId))
.Select(lc => lc.ParentId)
.Distinct()
.ToHashSet();
}
List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null;
List<BaseItem>? allVersions = null;
foreach (var video in candidates)
{
// Only items that actually have alternate versions aggregate over them.
if (video.LinkedAlternateVersions.Length == 0
&& (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id)))
{
continue;
}
var versions = video.GetAllVersions();
if (versions.Count < 2)
{
continue;
}
(versionGroups ??= []).Add((video.Id, versions));
(allVersions ??= []).AddRange(versions);
}
if (versionGroups is null)
{
return result;
}
var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user);
foreach (var (primaryId, versions) in versionGroups)
{
// Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
versions,
version => userDataByVersion.GetValueOrDefault(version.Id),
data => data.PlaybackPositionTicks > 0 || data.Played);
if (resumeVersion is not null)
{
result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]);
}
}
return result;
}
/// <summary> /// <summary>
/// Gets the internal key. /// Gets the internal key.
/// </summary> /// </summary>
@@ -378,10 +281,6 @@ namespace Emby.Server.Implementations.Library
var dto = GetUserItemDataDto(userData, item.Id); var dto = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto, userData, itemDto, user, options); item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
// For an item with alternate versions, surface the most recently played version's resume point.
GetResumeUserData(user, item)?.ApplyTo(dto);
return dto; return dto;
} }
@@ -486,41 +385,5 @@ namespace Emby.Server.Implementations.Library
return playedToCompletion; return playedToCompletion;
} }
/// <inheritdoc />
public void ResetPlaybackStreamSelections(User user, BaseItem item)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(item);
using var dbContext = _repository.CreateDbContext();
var rows = dbContext.UserData
.Where(e => e.ItemId == item.Id && e.UserId == user.Id
&& (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null))
.ToList();
if (rows.Count == 0)
{
return;
}
foreach (var row in rows)
{
row.AudioStreamIndex = null;
row.SubtitleStreamIndex = null;
}
dbContext.SaveChanges();
var cacheKey = GetCacheKey(user.InternalId, item.Id);
if (_cache.TryGet(cacheKey, out var cached))
{
cached.AudioStreamIndex = null;
cached.SubtitleStreamIndex = null;
_cache.AddOrUpdate(cacheKey, cached);
}
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
}
} }
} }

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

@@ -9,7 +9,7 @@
"Favorites": "Favoritter", "Favorites": "Favoritter",
"Folders": "Mapper", "Folders": "Mapper",
"Genres": "Genrer", "Genres": "Genrer",
"HeaderContinueWatching": "Fortsæt med at se", "HeaderContinueWatching": "Fortsæt afspilning",
"HeaderFavoriteEpisodes": "Yndlingsafsnit", "HeaderFavoriteEpisodes": "Yndlingsafsnit",
"HeaderFavoriteShows": "Yndlingsserier", "HeaderFavoriteShows": "Yndlingsserier",
"HeaderLiveTV": "Live-TV", "HeaderLiveTV": "Live-TV",
@@ -65,7 +65,7 @@
"TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.", "TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.",
"TaskDownloadMissingSubtitles": "Hent manglende undertekster", "TaskDownloadMissingSubtitles": "Hent manglende undertekster",
"TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.", "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.",
"TaskUpdatePlugins": "Opdatér plugins", "TaskUpdatePlugins": "Opdater plugins",
"TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.", "TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.",
"TaskCleanLogs": "Ryd log-mappe", "TaskCleanLogs": "Ryd log-mappe",
"TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.", "TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.",
@@ -79,10 +79,10 @@
"TaskRefreshChapterImages": "Udtræk kapitelbilleder", "TaskRefreshChapterImages": "Udtræk kapitelbilleder",
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.", "TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.", "TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
"TaskRefreshChannels": "Opdatér kanaler", "TaskRefreshChannels": "Opdater kanaler",
"TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.", "TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
"TaskCleanTranscode": "Tøm omkodningsmappen", "TaskCleanTranscode": "Tøm omkodningsmappen",
"TaskRefreshPeople": "Opdatér personer", "TaskRefreshPeople": "Opdater personer",
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.", "TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
"TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.", "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.",
"TaskCleanActivityLog": "Ryd aktivitetslog", "TaskCleanActivityLog": "Ryd aktivitetslog",
@@ -90,7 +90,7 @@
"Forced": "Tvunget", "Forced": "Tvunget",
"Default": "Standard", "Default": "Standard",
"TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.", "TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.",
"TaskOptimizeDatabase": "Optimér database", "TaskOptimizeDatabase": "Optimer database",
"TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.", "TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.",
"TaskKeyframeExtractor": "Udtræk nøglerammer", "TaskKeyframeExtractor": "Udtræk nøglerammer",
"External": "Ekstern", "External": "Ekstern",
@@ -99,7 +99,7 @@
"TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.", "TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.",
"TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.", "TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.",
"TaskAudioNormalization": "Lydnormalisering", "TaskAudioNormalization": "Lydnormalisering",
"TaskDownloadMissingLyricsDescription": "Download sangtekster", "TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen",
"TaskDownloadMissingLyrics": "Hent manglende sangtekster", "TaskDownloadMissingLyrics": "Hent manglende sangtekster",
"TaskExtractMediaSegments": "Scan for mediesegmenter", "TaskExtractMediaSegments": "Scan for mediesegmenter",
"TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder", "TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder",

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,7 +106,5 @@
"TaskExtractMediaSegments": "Escaneo de segmentos de medios", "TaskExtractMediaSegments": "Escaneo de segmentos de medios",
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
"CleanupUserDataTask": "Tarea de limpieza de datos de usuario", "CleanupUserDataTask": "Tarea de limpieza de datos de usuario",
"CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días."
"LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}",
"Original": "Original"
} }

View File

@@ -1,7 +1,7 @@
{ {
"Artists": "Listafólk", "Artists": "Listafólk",
"Collections": "Søvn", "Collections": "Søvn",
"Default": "Forsett", "Default": "Sjálvgildi",
"External": "Ytri", "External": "Ytri",
"Genres": "Greinar", "Genres": "Greinar",
"AppDeviceValues": "App: {0}, Eind: {1}", "AppDeviceValues": "App: {0}, Eind: {1}",
@@ -12,39 +12,5 @@
"Forced": "Kravt", "Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}", "FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar", "HeaderFavoriteEpisodes": "Yndispartar",
"LabelIpAddressValue": "IP-atsetur: {0}", "LabelIpAddressValue": "IP atsetur: {0}"
"AuthenticationSucceededWithUserName": "{0} varð samgildur",
"HeaderFavoriteShows": "Yndisrøðir",
"HeaderLiveTV": "Beinleiðis sjónvarp",
"HearingImpaired": "Hoyrnarveik",
"Inherit": "Arvar",
"LabelRunningTimeValue": "Spælitíð: {0}",
"Latest": "Seinastu",
"LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}",
"NameInstallFailed": "{0} innlegging miseydnaðist",
"NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
"NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
"NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
"NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
"NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
"NotificationOptionUserLockedOut": "Brúkari útihýstur",
"Photos": "Ljósmyndir",
"PluginInstalledWithName": "{0} var innlagt",
"PluginUninstalledWithName": "{0} var strikað",
"PluginUpdatedWithName": "{0} varð dagført",
"Shows": "Røðir",
"SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}",
"TvShows": "Sjónvarpsrøðir",
"UserCreatedWithName": "Brúkari {0} er stovnaður",
"UserDeletedWithName": "Brúkari {0} er strikaður",
"UserDownloadingItemWithValues": "{0} niðurtekur {1}",
"UserLockedOutWithName": "Brúkari {0} er útihýstur",
"VersionNumber": "Útgáva {0}",
"TasksLibraryCategory": "Savn",
"TaskRefreshLibrary": "Skanna miðlasavn",
"TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.",
"TaskUpdatePlugins": "Dagfør ískoytisforrit",
"TaskRefreshChannels": "Endurinnles rásir",
"TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir",
"Movies": "Filmar"
} }

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 @@
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.", "TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.", "TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.", "CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
"CleanupUserDataTask": "משימת ניקוי מידע משתמש", "CleanupUserDataTask": "משימת ניקוי מידע משתמש"
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
"Original": "מקור"
} }

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

@@ -103,8 +103,5 @@
"TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar", "TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar",
"TaskExtractMediaSegments": "Skönnun efnishluta", "TaskExtractMediaSegments": "Skönnun efnishluta",
"CleanupUserDataTask": "Hreinsun notendagagna", "CleanupUserDataTask": "Hreinsun notendagagna",
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.", "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga."
"LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}",
"Original": "Upprunaleg",
"TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt."
} }

View File

@@ -106,7 +106,5 @@
"TaskDownloadMissingLyrics": "失われた歌詞をダウンロード", "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。", "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク", "CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。", "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。"
"LyricDownloadFailureFromForItem": "歌詞",
"Original": "オリジナル"
} }

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

@@ -1,15 +1 @@
{ {}
"AppDeviceValues": "ඇප්: {0}, උපාංග: {1}",
"Artists": "කලාකරුවන්",
"AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී",
"Books": "පොත්",
"ChapterNameValue": "{0} වෙනි පරිච්ඡේදය",
"Collections": "සංහිතා",
"Default": "පෙරනිමි",
"External": "බාහිර",
"FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය",
"Favorites": "ප්‍රියතමයන්",
"Folders": "ෆෝල්ඩර",
"Forced": "නියමිත",
"Genres": "ප්‍රභේද"
}

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 @@
"TaskAudioNormalization": "Normalizimi i audios", "TaskAudioNormalization": "Normalizimi i audios",
"TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.", "TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.",
"CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.", "CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve", "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve"
"LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
"Original": "Origjinal"
} }

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

@@ -77,8 +77,8 @@
"TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。", "TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。",
"TaskCleanLogs": "清理日誌資料夾", "TaskCleanLogs": "清理日誌資料夾",
"TaskRefreshLibrary": "掃描媒體櫃", "TaskRefreshLibrary": "掃描媒體櫃",
"TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。", "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節圖。",
"TaskRefreshChapterImages": "擷取章節圖", "TaskRefreshChapterImages": "擷取章節圖",
"TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。", "TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。",
"TaskCleanCache": "清理快取Cache資料夾", "TaskCleanCache": "清理快取Cache資料夾",
"TasksChannelsCategory": "網路頻道", "TasksChannelsCategory": "網路頻道",
@@ -106,7 +106,5 @@
"TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay快轉預覽檔案搬去對應位置。", "TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay快轉預覽檔案搬去對應位置。",
"TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置", "TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置",
"CleanupUserDataTask": "清理使用者資料嘅任務", "CleanupUserDataTask": "清理使用者資料嘅任務",
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。", "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。"
"LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
"Original": "原始"
} }

View File

@@ -106,7 +106,5 @@
"TaskMoveTrickplayImages": "遷移快轉縮圖位置", "TaskMoveTrickplayImages": "遷移快轉縮圖位置",
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。", "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
"CleanupUserDataTask": "用戶資料清理工作", "CleanupUserDataTask": "用戶資料清理工作",
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。", "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
"Original": "原作",
"LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
} }

View File

@@ -356,27 +356,6 @@ namespace Emby.Server.Implementations.Localization
{ {
ArgumentException.ThrowIfNullOrEmpty(rating); ArgumentException.ThrowIfNullOrEmpty(rating);
// Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år").
// Try each one in order and use the first that resolves.
var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var ratingValue in ratingValues)
{
var score = GetSingleRatingScore(ratingValue, countryCode);
if (score is not null)
{
return score;
}
}
return null;
}
/// <summary>
/// Resolves a single rating value to a score.
/// </summary>
private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode)
{
// Handle unrated content // Handle unrated content
if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
{ {
@@ -385,7 +364,7 @@ namespace Emby.Server.Implementations.Localization
// Convert ints directly // Convert ints directly
// This may override some of the locale specific age ratings (but those always map to the same age) // This may override some of the locale specific age ratings (but those always map to the same age)
if (TryParseRatingAsScore(rating, out var ratingAge)) if (int.TryParse(rating, out var ratingAge))
{ {
return new(ratingAge, null); return new(ratingAge, null);
} }
@@ -487,13 +466,6 @@ namespace Emby.Server.Implementations.Localization
return true; return true;
} }
// If it's not a recognized rating string, fall back to using the number as the score
if (TryParseRatingAsScore(ratingPart, out var numericScore))
{
result = new ParentalRatingScore(numericScore, null);
return true;
}
_logger.LogWarning( _logger.LogWarning(
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
rating, rating,
@@ -508,18 +480,6 @@ namespace Emby.Server.Implementations.Localization
return true; return true;
} }
/// <summary>
/// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+").
/// </summary>
/// <param name="ratingValue">Rating value to parse.</param>
/// <param name="score">Parsed score.</param>
/// <returns>Returns true if parsing was successful.</returns>
private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
{
var trimmed = ratingValue.TrimEnd('+');
return int.TryParse(trimmed, out score);
}
/// <inheritdoc /> /// <inheritdoc />
public string GetLocalizedString(string phrase) public string GetLocalizedString(string phrase)
{ {
@@ -606,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

@@ -10,42 +10,21 @@
} }
}, },
{ {
"ratingStrings": ["K12", "K-12", "12"], "ratingStrings": ["K12"],
"ratingScore": {
"score": 12,
"subScore": null
}
},
{
"ratingStrings": ["K13", "K-13", "13"],
"ratingScore": { "ratingScore": {
"score": 13, "score": 13,
"subScore": null "subScore": null
} }
}, },
{ {
"ratingStrings": ["K15", "K-15", "15"], "ratingStrings": ["K15"],
"ratingScore": { "ratingScore": {
"score": 15, "score": 15,
"subScore": null "subScore": null
} }
}, },
{ {
"ratingStrings": ["K16", "K-16", "16"], "ratingStrings": ["K18"],
"ratingScore": {
"score": 16,
"subScore": null
}
},
{
"ratingStrings": ["K17", "K-17", "17"],
"ratingScore": {
"score": 17,
"subScore": null
}
},
{
"ratingStrings": ["K18", "K-18", "18", "18+"],
"ratingScore": { "ratingScore": {
"score": 18, "score": 18,
"subScore": null "subScore": null

View File

@@ -10,7 +10,7 @@
} }
}, },
{ {
"ratingStrings": ["7", "7+", "7 År", "Från 7 år"], "ratingStrings": ["7"],
"ratingScore": { "ratingScore": {
"score": 7, "score": 7,
"subScore": null "subScore": null
@@ -31,7 +31,7 @@
} }
}, },
{ {
"ratingStrings": ["11", "11+", "11 År", "Från 11 år"], "ratingStrings": ["11"],
"ratingScore": { "ratingScore": {
"score": 11, "score": 11,
"subScore": null "subScore": null
@@ -45,18 +45,11 @@
} }
}, },
{ {
"ratingStrings": ["15", "15+", "15 År", "Från 15 år"], "ratingStrings": ["15"],
"ratingScore": { "ratingScore": {
"score": 15, "score": 15,
"subScore": null "subScore": null
} }
},
{
"ratingStrings": ["18", "18+", "Barnförbjuden", "Bfj"],
"ratingScore": {
"score": 18,
"subScore": null
}
} }
] ]
} }

View File

@@ -4,7 +4,6 @@ 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.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -235,7 +234,7 @@ public partial class AudioNormalizationTask : IScheduledTask
{ {
FileName = _mediaEncoder.EncoderPath, FileName = _mediaEncoder.EncoderPath,
Arguments = args, Arguments = args,
StandardErrorEncoding = Encoding.UTF8, RedirectStandardOutput = false,
RedirectStandardError = true RedirectStandardError = true
}, },
}) })

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

@@ -4,18 +4,11 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
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;
@@ -27,9 +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 IFileSystem _fileSystem;
private readonly ILogger<PeopleValidationTask> _logger;
private readonly IItemTypeLookup _itemTypeLookup;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class. /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
@@ -37,23 +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="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory)
/// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
/// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
public PeopleValidationTask(
ILibraryManager libraryManager,
ILocalizationManager localization,
IDbContextFactory<JellyfinDbContext> dbContextFactory,
IFileSystem fileSystem,
ILogger<PeopleValidationTask> logger,
IItemTypeLookup itemTypeLookup)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
_localization = localization; _localization = localization;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_logger = logger;
_itemTypeLookup = itemTypeLookup;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -93,19 +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;
}
// Phase 1: Deduplicate and remove orphaned people (0-33%) 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 / 3));
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)
@@ -151,113 +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);
} }
// Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
// cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
// Phase 3: Refresh images for people missing them (66-100%)
IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false);
progress.Report(100);
}
private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
const int PartitionSize = 100;
var numPeople = await context.BaseItems
.AsNoTracking()
.Where(b => b.Type == personTypeName)
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
.Where(b =>
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
string.IsNullOrEmpty(b.Overview))
.CountAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople);
if (numPeople == 0)
{
progress.Report(100);
return;
}
var numComplete = 0;
var numRefreshed = 0;
await foreach (var entry in context.BaseItems
.AsNoTracking()
.Where(b => b.Type == personTypeName)
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
.Where(b =>
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
string.IsNullOrEmpty(b.Overview))
.OrderBy(b => b.Id)
.WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition))
.PartitionEagerAsync(PartitionSize, cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false))
{
numRefreshed++;
}
numComplete++;
progress.Report(100.0 * numComplete / numPeople);
}
_logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
}
}
private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
{
try
{
if (_libraryManager.GetItemById(personId) is not Person item)
{
return false;
}
var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary);
var hasOverview = !string.IsNullOrEmpty(item.Overview);
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
};
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing images for person {PersonId}", personId);
return false;
}
} }
} }

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)
{ {
@@ -729,31 +725,6 @@ namespace Emby.Server.Implementations.Session
return item; return item;
} }
/// <summary>
/// Resolves the item whose user data (playback position, played status) should be updated
/// for a playback report. When an alternate version is played the client reports the displayed
/// item as <c>ItemId</c> and the played version as <c>MediaSourceId</c>.
/// </summary>
/// <param name="libraryItem">The now playing (displayed) item.</param>
/// <param name="mediaSourceId">The reported media source id.</param>
/// <returns>The item to track progress against.</returns>
private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId)
{
if (libraryItem is Video libraryVideo
&& !string.IsNullOrEmpty(mediaSourceId)
&& Guid.TryParse(mediaSourceId, out var mediaSourceItemId)
&& !mediaSourceItemId.Equals(libraryVideo.Id))
{
var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId);
if (versionItem is not null)
{
return versionItem;
}
}
return libraryItem;
}
/// <summary> /// <summary>
/// Used to report that playback has started for an item. /// Used to report that playback has started for an item.
/// </summary> /// </summary>
@@ -785,10 +756,9 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null) if (libraryItem is not null)
{ {
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users) foreach (var user in users)
{ {
OnPlaybackStart(user, progressItem); OnPlaybackStart(user, libraryItem);
} }
} }
@@ -920,10 +890,9 @@ namespace Emby.Server.Implementations.Session
// only update saved user data on actual check-ins, not automated ones // only update saved user data on actual check-ins, not automated ones
if (libraryItem is not null && !isAutomated) if (libraryItem is not null && !isAutomated)
{ {
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users) foreach (var user in users)
{ {
OnPlaybackProgress(user, progressItem, info); OnPlaybackProgress(user, libraryItem, info);
} }
} }
@@ -983,20 +952,6 @@ namespace Emby.Server.Implementations.Session
if (changed) if (changed)
{ {
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None); _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None);
// A completed version marks every alternate version played and clears their resume points, so the
// whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
// only persist while nothing has been completed yet.)
if (data.Played == true && item is Video playedVideo)
{
playedVideo.PropagatePlayedState(user, true);
}
}
if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue)
|| (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue))
{
_userDataManager.ResetPlaybackStreamSelections(user, item);
} }
} }
@@ -1128,10 +1083,9 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null) if (libraryItem is not null)
{ {
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users) foreach (var user in users)
{ {
playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed); playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed);
} }
} }
@@ -1184,14 +1138,6 @@ namespace Emby.Server.Implementations.Session
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None); _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None);
// A completed version marks every alternate version played and clears their resume points, so the
// whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
// only persist while nothing has been completed yet.)
if (data.Played == true && item is Video playedVideo)
{
playedVideo.PropagatePlayedState(user, true);
}
return playedToCompletion; return playedToCompletion;
} }

View File

@@ -246,21 +246,8 @@ namespace Emby.Server.Implementations.Session
_logger.LogInformation("Lost {0} WebSockets.", lost.Count); _logger.LogInformation("Lost {0} WebSockets.", lost.Count);
foreach (var webSocket in lost) foreach (var webSocket in lost)
{ {
// TODO: handle session relative to the lost webSocket
RemoveWebSocket(webSocket); RemoveWebSocket(webSocket);
// The connection stopped answering keep-alives, so a close frame will
// never arrive and the pending receive loop would hang forever, keeping
// the session (and e.g. its SyncPlay group membership) alive. Disposing
// the connection aborts the receive loop, which raises Closed and lets
// the session end normally.
try
{
webSocket.Dispose();
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint);
}
} }
} }
} }

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

@@ -2,7 +2,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using Jellyfin.Data; using Jellyfin.Data;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
@@ -137,15 +136,11 @@ namespace Emby.Server.Implementations.TV
if (nextEpisode is not null) if (nextEpisode is not null)
{ {
// The last played date and the version that was actually played live on the version item's user data
// The played state propagated to the sibling versions carries no date
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user);
nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion);
DateTime lastWatchedDate = DateTime.MinValue; DateTime lastWatchedDate = DateTime.MinValue;
if (result.LastWatched is not null) if (result.LastWatched is not null)
{ {
lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); var userData = _userDataManager.GetUserData(user, result.LastWatched);
lastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
} }
nextUpList.Add((lastWatchedDate, nextEpisode)); nextUpList.Add((lastWatchedDate, nextEpisode));
@@ -157,13 +152,11 @@ namespace Emby.Server.Implementations.TV
if (nextPlayedEpisode is not null) if (nextPlayedEpisode is not null)
{ {
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user);
nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion);
DateTime rewatchLastWatchedDate = DateTime.MinValue; DateTime rewatchLastWatchedDate = DateTime.MinValue;
if (result.LastWatchedForRewatching is not null) if (result.LastWatchedForRewatching is not null)
{ {
rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); var userData = _userDataManager.GetUserData(user, result.LastWatchedForRewatching);
rewatchLastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
} }
nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode)); nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
@@ -226,13 +219,10 @@ namespace Emby.Server.Implementations.TV
if (nextEpisode is not null && !includeResumable) if (nextEpisode is not null && !includeResumable)
{ {
// The resume progress may live on an alternate version var userData = _userDataManager.GetUserData(user, nextEpisode);
foreach (var version in nextEpisode.GetAllVersions()) if (userData?.PlaybackPositionTicks > 0)
{ {
if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0) return null;
{
return null;
}
} }
} }
@@ -247,74 +237,6 @@ namespace Emby.Server.Implementations.TV
return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true); return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
} }
/// <summary>
/// Gets the version of the last watched episode that was actually played, together with its last played date.
/// The version that was played carries the most recent LastPlayedDate.
/// dates.
/// </summary>
/// <param name="lastWatched">The last watched episode (any version).</param>
/// <param name="user">The user.</param>
/// <returns>The played version and its last played date.</returns>
private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user)
{
if (lastWatched is not Video lastWatchedVideo)
{
return (null, null);
}
var versions = lastWatchedVideo.GetAllVersions();
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
versions,
version => userDataByVersion.GetValueOrDefault(version.Id),
data => data.LastPlayedDate.HasValue);
return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
}
/// <summary>
/// When the last watched episode was played as an alternate version, prefer the next episode's version with the matching name,
/// so Next Up continues in the version the user has been watching instead of falling back to the primary.
/// </summary>
/// <param name="nextEpisode">The determined next episode (a primary).</param>
/// <param name="lastWatched">The last watched episode.</param>
/// <param name="playedVersion">The version of the last watched episode that was played.</param>
/// <returns>The matching version of the next episode, or the episode itself.</returns>
private Episode GetPreferredVersion(Episode nextEpisode, BaseItem? lastWatched, Video? playedVersion)
{
// No version preference, or the primary was played
if (lastWatched is not Video lastWatchedVideo
|| playedVersion is null
|| !playedVersion.PrimaryVersionId.HasValue)
{
return nextEpisode;
}
// Match by version name
var playedVersionId = playedVersion.Id.ToString("N", CultureInfo.InvariantCulture);
var playedVersionName = lastWatchedVideo.GetMediaSources(false)
.FirstOrDefault(source => string.Equals(source.Id, playedVersionId, StringComparison.OrdinalIgnoreCase))?.Name;
if (string.IsNullOrEmpty(playedVersionName))
{
return nextEpisode;
}
var matchingSource = nextEpisode.GetMediaSources(false)
.FirstOrDefault(source => string.Equals(source.Name, playedVersionName, StringComparison.OrdinalIgnoreCase));
if (matchingSource is not null
&& Guid.TryParse(matchingSource.Id, out var matchingId)
&& !matchingId.Equals(nextEpisode.Id)
&& _libraryManager.GetItemById<Episode>(matchingId) is { } matchingVersion)
{
return matchingVersion;
}
return nextEpisode;
}
private static string GetUniqueSeriesKey(Series series) private static string GetUniqueSeriesKey(Series series)
{ {
return series.GetPresentationUniqueKey(); return series.GetPresentationUniqueKey();

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

@@ -60,8 +60,11 @@ public class HlsSegmentController : BaseJellyfinApiController
public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
{ {
// TODO: Deprecate with new iOS app // TODO: Deprecate with new iOS app
var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
if (file is null) var transcodePath = _serverConfigurationManager.GetTranscodePath();
file = Path.GetFullPath(Path.Combine(transcodePath, file));
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
{ {
return BadRequest("Invalid segment."); return BadRequest("Invalid segment.");
} }
@@ -83,9 +86,12 @@ public class HlsSegmentController : BaseJellyfinApiController
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
{ {
var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()))); var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()));
if (file is null var transcodePath = _serverConfigurationManager.GetTranscodePath();
|| !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) file = Path.GetFullPath(Path.Combine(transcodePath, file));
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)
|| Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
{ {
return BadRequest("Invalid segment."); return BadRequest("Invalid segment.");
} }
@@ -134,13 +140,18 @@ public class HlsSegmentController : BaseJellyfinApiController
[FromRoute, Required] string segmentId, [FromRoute, Required] string segmentId,
[FromRoute, Required] string segmentContainer) [FromRoute, Required] string segmentContainer)
{ {
var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
if (file is null) var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
{ {
return BadRequest("Invalid segment."); return BadRequest("Invalid segment.");
} }
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); var normalizedPlaylistId = playlistId;
var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
// Add . to start of segment container for future use. // Add . to start of segment container for future use.
segmentContainer = segmentContainer.Insert(0, "."); segmentContainer = segmentContainer.Insert(0, ".");
@@ -150,7 +161,7 @@ public class HlsSegmentController : BaseJellyfinApiController
var pathExtension = Path.GetExtension(path); var pathExtension = Path.GetExtension(path);
if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
|| string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
&& path.Contains(playlistId, StringComparison.OrdinalIgnoreCase)) && path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase))
{ {
playlistPath = path; playlistPath = path;
break; break;
@@ -162,19 +173,6 @@ public class HlsSegmentController : BaseJellyfinApiController
: GetFileResult(file, playlistPath); : GetFileResult(file, playlistPath);
} }
private string? ValidateTranscodePath(string filename)
{
var transcodePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_serverConfigurationManager.GetTranscodePath()));
var file = Path.GetFullPath(filename, transcodePath);
// Require a separator after the transcode path so a sibling like "<transcodePath>-evil" can't pass.
if (!file.StartsWith(transcodePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return file;
}
private ActionResult GetFileResult(string path, string playlistPath) private ActionResult GetFileResult(string path, string playlistPath)
{ {
var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);

View File

@@ -2049,7 +2049,7 @@ public class ImageController : BaseJellyfinApiController
} }
// Check If-Modified-Since header for time-based validation // Check If-Modified-Since header for time-based validation
if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], CultureInfo.InvariantCulture, out var ifModifiedSinceHeader)) if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader))
{ {
// Return 304 if the image has not been modified since the client's cached version // Return 304 if the image has not been modified since the client's cached version
if (dateImageModified <= ifModifiedSinceHeader) if (dateImageModified <= ifModifiedSinceHeader)

View File

@@ -279,11 +279,6 @@ public class ItemUpdateController : BaseJellyfinApiController
item.DateCreated = NormalizeDateTime(request.DateCreated.Value); item.DateCreated = NormalizeDateTime(request.DateCreated.Value);
} }
if (request.SeriesName is not null && item is IHasSeries hasSeries)
{
hasSeries.SeriesName = request.SeriesName;
}
item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
item.ProductionYear = request.ProductionYear; item.ProductionYear = request.ProductionYear;
@@ -293,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;
@@ -302,8 +297,6 @@ public class ItemUpdateController : BaseJellyfinApiController
{ {
foreach (var season in rseries.Children.OfType<Season>()) foreach (var season in rseries.Children.OfType<Season>())
{ {
season.SeriesName = rseries.Name;
if (!season.LockedFields.Contains(MetadataField.OfficialRating)) if (!season.LockedFields.Contains(MetadataField.OfficialRating))
{ {
season.OfficialRating = request.OfficialRating; season.OfficialRating = request.OfficialRating;
@@ -321,8 +314,6 @@ public class ItemUpdateController : BaseJellyfinApiController
foreach (var ep in season.Children.OfType<Episode>()) foreach (var ep in season.Children.OfType<Episode>())
{ {
ep.SeriesName = rseries.Name;
if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
{ {
ep.OfficialRating = request.OfficialRating; ep.OfficialRating = request.OfficialRating;

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>
@@ -287,8 +282,6 @@ public class ItemsController : BaseJellyfinApiController
QueryResult<BaseItem> result; QueryResult<BaseItem> result;
Guid[] linkedChildAncestorIds = []; Guid[] linkedChildAncestorIds = [];
includeItemTypes ??= [];
if (includeItemTypes.Length == 1 if (includeItemTypes.Length == 1
&& (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist) && (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist)
&& item is not BoxSet && item is not BoxSet
@@ -321,22 +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],
_ => []
};
}
// 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
@@ -349,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
{ {
@@ -963,21 +901,15 @@ public class ItemsController : BaseJellyfinApiController
var excludeItemIds = Array.Empty<Guid>(); var excludeItemIds = Array.Empty<Guid>();
if (excludeActiveSessions) if (excludeActiveSessions)
{ {
// NowPlayingItem.Id is the displayed/primary id, but resume queries surface the actually-played
// alternate version's own id. Expand each active session to every version id so an in-progress
// alternate is excluded too, instead of leaking back into the resume list.
excludeItemIds = _sessionManager.Sessions excludeItemIds = _sessionManager.Sessions
.Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null) .Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null)
.SelectMany(s => _libraryManager.GetItemById(s.NowPlayingItem.Id) is Video video .Select(s => s.NowPlayingItem.Id)
? video.GetAllVersions().Select(v => v.Id)
: [s.NowPlayingItem.Id])
.Distinct()
.ToArray(); .ToArray();
} }
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,
@@ -987,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

@@ -226,13 +226,10 @@ public class PluginsController : BaseJellyfinApiController
return NotFound(); return NotFound();
} }
string? imagePath = plugin.Manifest.ImagePath; if (!string.IsNullOrEmpty(plugin.Manifest.ImagePath))
if (!string.IsNullOrWhiteSpace(imagePath))
{ {
var pluginPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(plugin.Path)); var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath);
imagePath = Path.GetFullPath(imagePath, pluginPath); if (!System.IO.File.Exists(imagePath))
// Require a separator after the plugin path so a sibling like "<pluginPath>-evil" can't pass.
if (imagePath.StartsWith(pluginPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false)
{ {
return NotFound(); return NotFound();
} }

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");
} }
@@ -277,15 +277,8 @@ public class TvShowsController : BaseJellyfinApiController
if (startItemId.HasValue) if (startItemId.HasValue)
{ {
// The start item may be an alternate version, which is not part of the episode listing; start from its primary episode instead.
var startId = startItemId.Value;
if (_libraryManager.GetItemById<Video>(startId)?.PrimaryVersionId is { } primaryVersionId)
{
startId = primaryVersionId;
}
episodes = episodes episodes = episodes
.SkipWhile(i => !startId.Equals(i.Id)) .SkipWhile(i => !startItemId.Value.Equals(i.Id))
.ToList(); .ToList();
} }

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)));
} }
@@ -551,6 +557,8 @@ public class UserLibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields } var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
dtoOptions.PreferEpisodeParentPoster = true;
var list = _userViewManager.GetLatestItems( var list = _userViewManager.GetLatestItems(
new LatestItemsQuery new LatestItemsQuery
{ {

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

@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Dto;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
@@ -10,37 +9,6 @@ namespace Jellyfin.Api.Extensions;
/// </summary> /// </summary>
public static class DtoExtensions public static class DtoExtensions
{ {
/// <summary>
/// Gets the BaseItemKind values associated with the specified CollectionType.
/// </summary>
/// <param name="collectionType">The collection type to map to BaseItemKind values.</param>
/// <returns>An array of BaseItemKind values that correspond to the collection type.</returns>
public static BaseItemKind[] GetBaseItemKindsForCollectionType(CollectionType? collectionType)
{
switch (collectionType)
{
case CollectionType.movies:
return [BaseItemKind.Movie];
case CollectionType.tvshows:
return [BaseItemKind.Series];
case CollectionType.music:
return [BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist];
case CollectionType.musicvideos:
return [BaseItemKind.MusicVideo];
case CollectionType.books:
return [BaseItemKind.Book, BaseItemKind.AudioBook];
case CollectionType.boxsets:
return [BaseItemKind.BoxSet];
case CollectionType.homevideos:
case CollectionType.photos:
return [BaseItemKind.Video, BaseItemKind.Photo];
case CollectionType.folders:
return [];
default:
return [BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series];
}
}
/// <summary> /// <summary>
/// Add additional DtoOptions. /// Add additional DtoOptions.
/// </summary> /// </summary>

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

@@ -144,15 +144,6 @@ public static class StreamingHelpers
mediaSource = liveStreamInfo.Item1; mediaSource = liveStreamInfo.Item1;
state.DirectStreamProvider = liveStreamInfo.Item2; state.DirectStreamProvider = liveStreamInfo.Item2;
// The requested live stream is no longer open. This commonly happens when a client keeps
// polling the HLS playlist (e.g. live.m3u8) after the stream was disposed because its
// consumer count dropped to zero. GetLiveStreamWithDirectStreamProvider returns a null
// MediaSource in that case, so return 404 instead of dereferencing it below.
if (mediaSource is null)
{
throw new ResourceNotFoundException($"The live stream with id {streamingRequest.LiveStreamId} could not be found or is no longer available.");
}
// Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate. // Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate.
if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null) if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null)
{ {

View File

@@ -56,11 +56,11 @@ public class ActivityManager : IActivityManager
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
var entries = dbContext.ActivityLogs.LeftJoin( // TODO switch to LeftJoin in .NET 10.
dbContext.Users, var entries = from a in dbContext.ActivityLogs
a => a.UserId, join u in dbContext.Users on a.UserId equals u.Id into ugj
u => u.Id, from u in ugj.DefaultIfEmpty()
(a, u) => new ExpandedActivityLog { ActivityLog = a, Username = u == null ? null : u.Username }); select new ExpandedActivityLog { ActivityLog = a, Username = u.Username };
if (query.HasUserId is not null) if (query.HasUserId is not null)
{ {

View File

@@ -213,10 +213,8 @@ namespace Jellyfin.Server.Implementations.Devices
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
await dbContext.Devices dbContext.Devices.Remove(device);
.Where(d => d.Id == device.Id) await dbContext.SaveChangesAsync().ConfigureAwait(false);
.ExecuteDeleteAsync()
.ConfigureAwait(false);
} }
} }

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

@@ -62,21 +62,18 @@ public sealed partial class BaseItemRepository
private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{ {
// Collapse duplicates sharing a presentation key (e.g. alternate versions), preferring the // Collapse duplicates sharing a presentation key (e.g. alternate versions) by picking
// primary version (PrimaryVersionId is null) so detail pages and actions target it instead // the min Id per group. Keep the grouped ids as an IQueryable sub-select; materializing
// of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing
// to a List would inline one bound parameter per id and hit SQLite's variable cap. // to a List would inline one bound parameter per id and hit SQLite's variable cap.
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter); var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey) if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
{ {
var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }) var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.Min(x => x.Id));
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id)); dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
} }
else if (enableGroupByPresentationUniqueKey) else if (enableGroupByPresentationUniqueKey)
{ {
var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey) var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.Min(x => x.Id));
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id)); dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
} }
else if (filter.GroupBySeriesPresentationUniqueKey) else if (filter.GroupBySeriesPresentationUniqueKey)

View File

@@ -513,17 +513,13 @@ public sealed partial class BaseItemRepository
if (filter.IsResumable.HasValue) if (filter.IsResumable.HasValue)
{ {
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
var userId = filter.User!.Id;
var isResumable = filter.IsResumable.Value;
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
// In-progress user data rows; alternate versions track their own progress.
var inProgress = context.UserData
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
IQueryable<Guid>? resumableSeriesIds = null;
if (hasSeries) if (hasSeries)
{ {
var userId = filter.User!.Id;
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var isResumable = filter.IsResumable.Value;
// Aggregate per series in a single GROUP BY pass, instead of three full scans. // Aggregate per series in a single GROUP BY pass, instead of three full scans.
var seriesEpisodeStats = context.BaseItems var seriesEpisodeStats = context.BaseItems
.AsNoTracking() .AsNoTracking()
@@ -539,44 +535,26 @@ public sealed partial class BaseItemRepository
// A series is resumable if it has an in-progress episode, // A series is resumable if it has an in-progress episode,
// or if it has both played and unplayed episodes (partially watched). // or if it has both played and unplayed episodes (partially watched).
resumableSeriesIds = seriesEpisodeStats var resumableSeriesIds = seriesEpisodeStats
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
.Select(s => s.SeriesId); .Select(s => s.SeriesId);
}
if (isResumable) // Non-series items: resumable if PlaybackPositionTicks > 0
{ var resumableItemIds = context.UserData
// Resume queries surface the version that was actually played, which may be an alternate. .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)
// Match each version on its own progress rather than coalescing onto the primary. .Select(ud => ud.ItemId);
var inProgressIds = inProgress.Select(ud => ud.ItemId);
baseQuery = hasSeries baseQuery = baseQuery.Where(e =>
? baseQuery.Where(e => (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable)
(e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id)) || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable));
|| (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
: baseQuery.Where(e => inProgressIds.Contains(e.Id));
// When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|| (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
== inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
&& s.Id.CompareTo(e.Id) < 0)));
} }
else else
{ {
// Not-resumable queries operate on primaries only. var resumableItemIds = context.UserData
var resumableMovieIds = inProgress .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0)
.Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); .Select(ud => ud.ItemId);
var isResumable = filter.IsResumable.Value;
baseQuery = hasSeries baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable);
? baseQuery.Where(e =>
(e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id))
|| (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id)))
: baseQuery.Where(e => !resumableMovieIds.Contains(e.Id));
} }
} }
@@ -608,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)
@@ -763,13 +742,10 @@ public sealed partial class BaseItemRepository
} }
else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems) else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems)
{ {
// Exclude owned non-extra items from general queries. // Exclude alternate versions and owned non-extra items from general queries.
// Alternate versions have PrimaryVersionId set (pointing to their primary).
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those. // Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
// Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
// keep them so the actually-played version can surface instead of collapsing onto the primary.
baseQuery = filter.IsResumable == true
? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null)
: baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
} }
if (filter.OwnerIds.Length > 0) if (filter.OwnerIds.Length > 0)
@@ -977,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

@@ -167,14 +167,6 @@ public sealed partial class BaseItemRepository
return false; return false;
} }
// Resume queries surface the actually-played version (which may be an alternate sharing the
// primary's presentation key). The resumable filter already keeps one version per group, so
// presentation-key grouping must not collapse the surfaced version back onto the primary.
if (query.IsResumable == true)
{
return false;
}
if (query.GroupBySeriesPresentationUniqueKey) if (query.GroupBySeriesPresentationUniqueKey)
{ {
return false; return false;

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();

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