Merge branch 'master' into fix/livetv-channel-icon-refresh

Resolve GuideManager conflict by keeping LiveTvChannelImageHelper so
channel icons re-fetch on every guide refresh, including when the URL
is unchanged.
This commit is contained in:
Daniel Țuțuianu
2026-06-17 06:16:42 +03:00
155 changed files with 10139 additions and 1738 deletions

View File

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

View File

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

View File

@@ -1,11 +1,15 @@
<!--
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 documentation.
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.
-->
**Changes**
<!-- Describe your changes here in 1-5 sentences. -->
**Code assistance**
<!-- If code assistance was used, describe how it contributed
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
**Issues**
<!-- Tag any issues that this PR solves here.
ex. Fixes # -->

View File

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

View File

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

View File

@@ -15,9 +15,9 @@ jobs:
format-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: ${{ env.SDK_VERSION }}

View File

@@ -20,9 +20,9 @@ jobs:
runs-on: "${{ matrix.os }}"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: ${{ env.SDK_VERSION }}

View File

@@ -24,7 +24,7 @@ jobs:
reactions: '+1'
- name: Checkout the latest code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -40,7 +40,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: jellyfin/jellyfin-triage-script

View File

@@ -10,7 +10,7 @@ jobs:
issues: write
steps:
- name: pull in script
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: jellyfin/jellyfin-triage-script

View File

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

View File

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

View File

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

View File

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

4
.gitignore vendored
View File

@@ -278,3 +278,7 @@ apiclient/generated
# Omnisharp crash logs
mono_crash.*.json
# Devcontainer temp files
.devcontainer/devcontainer-lock.json
dotnet/

View File

@@ -90,6 +90,7 @@
- [mark-monteiro](https://github.com/mark-monteiro)
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
- [Martin Reuter](https://github.com/reuterma24)
- [Matt Teahan](https://github.com/matt-teahan)
- [Matt07211](https://github.com/Matt07211)
- [Matthew Jones](https://github.com/matthew-jones-uk)
- [Maxr1998](https://github.com/Maxr1998)
@@ -114,6 +115,7 @@
- [oddstr13](https://github.com/oddstr13)
- [olsh](https://github.com/olsh)
- [orryverducci](https://github.com/orryverducci)
- [PCEWLKR](https://github.com/PCEWLKR)
- [petermcneil](https://github.com/petermcneil)
- [Phlogi](https://github.com/Phlogi)
- [pjeanjean](https://github.com/pjeanjean)

View File

@@ -18,7 +18,7 @@
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.3" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<PackageVersion Include="Ignore" Version="0.2.1" />
@@ -26,28 +26,28 @@
<PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
<PackageVersion Include="Moq" Version="4.18.4" />
@@ -57,29 +57,29 @@
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="Polly" Version="8.6.6" />
<PackageVersion Include="Polly" Version="8.7.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpCompress" Version="0.49.1" />
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
<PackageVersion Include="SkiaSharp" Version="[3.116.1]" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="[3.116.1]" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="[3.116.1]" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.1.7" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.1" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.1" />
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="7.13.0" />
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
<PackageVersion Include="TMDbLib" Version="3.0.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />

View File

@@ -10,17 +10,25 @@ namespace Emby.Naming.TV
/// </summary>
public static partial class SeasonPathParser
{
private const string SeasonKeywordPattern =
@"시즌|シーズン|сезон" +
@"|season|sæson|saison|staffel|series|stagione|säsong|seizoen|seasong" +
@"|sezon|sezona|sezóna|sezonul|série|séria|serie|seria|temporada|kausi";
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPre();
[GeneratedRegex(@"^\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
[GeneratedRegex(@"^\s*(?:" + SeasonKeywordPattern + @")\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPost();
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
private static partial Regex SeasonPrefix();
[GeneratedRegex(SeasonKeywordPattern, RegexOptions.IgnoreCase)]
private static partial Regex SeasonKeyword();
/// <summary>
/// Attempts to parse season number from path.
/// </summary>
@@ -91,14 +99,25 @@ namespace Emby.Naming.TV
return (val, true);
}
bool isMixedLibrary = !supportNumericSeasonFolders && !supportSpecialAliases;
var preMatch = ProcessPre().Match(filename);
if (preMatch.Success)
{
if (isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
{
return (null, false);
}
return CheckMatch(preMatch);
}
else
{
var postMatch = ProcessPost().Match(filename);
if (postMatch.Success && isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
{
return (null, false);
}
return CheckMatch(postMatch);
}
}

View File

@@ -26,6 +26,7 @@ using Emby.Server.Implementations.Dto;
using Emby.Server.Implementations.HttpServer.Security;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library;
using Emby.Server.Implementations.Library.Search;
using Emby.Server.Implementations.Library.SimilarItems;
using Emby.Server.Implementations.Localization;
using Emby.Server.Implementations.Playlists;
@@ -539,6 +540,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
serviceCollection.AddSingleton<NamingOptions>();
serviceCollection.AddSingleton<VideoListResolver>();
@@ -550,7 +552,8 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
serviceCollection.AddSingleton<ISearchManager, SearchManager>();
serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
@@ -709,6 +712,7 @@ namespace Emby.Server.Implementations
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
}
/// <summary>

View File

@@ -4,12 +4,15 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections
private readonly ILibraryMonitor _iLibraryMonitor;
private readonly ILogger<CollectionManager> _logger;
private readonly IProviderManager _providerManager;
private readonly ILinkedChildrenService _linkedChildrenService;
private readonly ILocalizationManager _localizationManager;
private readonly IApplicationPaths _appPaths;
@@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections
/// <param name="iLibraryMonitor">The library monitor.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="providerManager">The provider manager.</param>
/// <param name="linkedChildrenService">The linked children service.</param>
public CollectionManager(
ILibraryManager libraryManager,
IApplicationPaths appPaths,
@@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections
IFileSystem fileSystem,
ILibraryMonitor iLibraryMonitor,
ILoggerFactory loggerFactory,
IProviderManager providerManager)
IProviderManager providerManager,
ILinkedChildrenService linkedChildrenService)
{
_libraryManager = libraryManager;
_fileSystem = fileSystem;
_iLibraryMonitor = iLibraryMonitor;
_logger = loggerFactory.CreateLogger<CollectionManager>();
_providerManager = providerManager;
_linkedChildrenService = linkedChildrenService;
_localizationManager = localizationManager;
_appPaths = appPaths;
}
@@ -120,6 +127,22 @@ namespace Emby.Server.Implementations.Collections
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
}
/// <inheritdoc />
public IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId)
{
ArgumentNullException.ThrowIfNull(user);
if (itemId.IsEmpty())
{
return Enumerable.Empty<BoxSet>();
}
return _linkedChildrenService
.GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet)
.Select(parentId => _libraryManager.GetItemById<BoxSet>(parentId, user))
.OfType<BoxSet>();
}
private IEnumerable<BoxSet> GetCollections(User user)
{
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();

View File

@@ -1366,6 +1366,41 @@ namespace Emby.Server.Implementations.Dto
}
}
if (options.PreferEpisodeParentPoster)
{
var episodeSeason = episode.Season;
var seasonPrimaryTag = episodeSeason is not null
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
: null;
BaseItem? posterParent = null;
if (seasonPrimaryTag is not null)
{
dto.ParentPrimaryImageItemId = episodeSeason!.Id;
dto.ParentPrimaryImageTag = seasonPrimaryTag;
posterParent = episodeSeason;
}
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
{
dto.ParentPrimaryImageItemId = episodeSeries.Id;
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);
}
}
if (options.ContainsField(ItemFields.SeriesStudio))
{
episodeSeries ??= episode.Series;
@@ -1504,6 +1539,21 @@ namespace Emby.Server.Implementations.Dto
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)
{
return;

View File

@@ -1,6 +1,5 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Chapters;
@@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager
/// <inheritdoc/>
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
{
var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
var itemId = item.Id;
if (validPaths.Count > 0)
{
foreach (var path in validPaths)
{
try
{
Directory.Delete(path, true);
}
catch (Exception ex)
{
_logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
}
}
}
DeleteExternalItemFiles(item);
var itemId = item.Id;
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public void DeleteExternalItemFiles(BaseItem item)
{
foreach (var path in _pathManager.GetExtractedDataPaths(item))
{
if (!Directory.Exists(path))
{
continue;
}
try
{
Directory.Delete(path, true);
}
catch (Exception ex)
{
_logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
}
}
}
}

View File

@@ -89,6 +89,7 @@ namespace Emby.Server.Implementations.Library
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
private readonly IMediaStreamRepository _mediaStreamRepository;
private readonly Lazy<IExternalDataManager> _externalDataManagerFactory;
/// <summary>
/// The _root folder sync lock.
@@ -132,6 +133,7 @@ namespace Emby.Server.Implementations.Library
/// <param name="pathManager">The path manager.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</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(
IServerApplicationHost appHost,
ILoggerFactory loggerFactory,
@@ -155,7 +157,8 @@ namespace Emby.Server.Implementations.Library
IPeopleRepository peopleRepository,
IPathManager pathManager,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
IMediaStreamRepository mediaStreamRepository)
IMediaStreamRepository mediaStreamRepository,
Lazy<IExternalDataManager> externalDataManagerFactory)
{
_appHost = appHost;
_logger = loggerFactory.CreateLogger<LibraryManager>();
@@ -186,6 +189,7 @@ namespace Emby.Server.Implementations.Library
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
_mediaStreamRepository = mediaStreamRepository;
_externalDataManagerFactory = externalDataManagerFactory;
RecordConfigurationValues(_configurationManager.Configuration);
}
@@ -396,6 +400,12 @@ 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)]);
}
@@ -576,6 +586,13 @@ namespace Emby.Server.Implementations.Library
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)]);
_cache.TryRemove(item.Id, out _);
foreach (var child in children)
@@ -1987,7 +2004,8 @@ namespace Emby.Server.Implementations.Library
query.TopParentIds.Length == 0 &&
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
query.ItemIds.Length == 0)
query.ItemIds.Length == 0 &&
query.OwnerIds.Length == 0)
{
var userViews = UserViewManager.GetUserViews(new UserViewQuery
{
@@ -2432,8 +2450,14 @@ namespace Emby.Server.Implementations.Library
var outdated = forceUpdate
? item.ImageInfos.Where(i => i.Path is not null).ToArray()
: item.ImageInfos.Where(ImageNeedsRefresh).ToArray();
// Skip image processing if current or live tv source
if (outdated.Length == 0 || item.SourceType != SourceType.Library)
var parentItem = item.GetParent();
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);
return;
@@ -3394,6 +3418,12 @@ namespace Emby.Server.Implementations.Library
return _peopleRepository.GetPeopleNames(query);
}
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
{
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
}
public void UpdatePeople(BaseItem item, List<PersonInfo> people)
{
UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult();

View File

@@ -24,6 +24,7 @@ using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding;
@@ -127,6 +128,11 @@ namespace Emby.Server.Implementations.Library
return true;
}
if (stream.IsVobSubSubtitleStream)
{
return true;
}
return false;
}
@@ -171,6 +177,7 @@ namespace Emby.Server.Implementations.Library
public async Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
{
var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
// If file is strm or main media stream is missing, force a metadata refresh with remote probing
if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder
@@ -187,6 +194,7 @@ namespace Emby.Server.Implementations.Library
cancellationToken).ConfigureAwait(false);
mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
}
var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false);
@@ -221,7 +229,7 @@ namespace Emby.Server.Implementations.Library
list.Add(source);
}
return SortMediaSources(list).ToArray();
return SortMediaSources(list, item.Id).ToArray();
}
/// <inheritdoc />>
@@ -319,6 +327,28 @@ namespace Emby.Server.Implementations.Library
}
}
/// <summary>
/// Resolves symlinked file paths on the supplied sources to the real on-disk target.
/// Skipped when <paramref name="enablePathSubstitution"/> is set because the path may
/// already have been rewritten to a UNC/URL meant for the client to consume directly.
/// </summary>
private static void ResolveSymlinkPaths(IReadOnlyList<MediaSourceInfo> sources, bool enablePathSubstitution)
{
if (enablePathSubstitution)
{
return;
}
foreach (var source in sources)
{
if (source.Protocol == MediaProtocol.File
&& FileSystemHelper.ResolveLinkTarget(source.Path, returnFinalTarget: true) is { Exists: true } target)
{
source.Path = target.FullName;
}
}
}
private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource)
{
var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter;
@@ -356,6 +386,12 @@ namespace Emby.Server.Implementations.Library
if (user is not null)
{
sources = sources
.Where(source => !Guid.TryParse(source.Id, out var sourceId)
|| sourceId.Equals(item.Id)
|| _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null)
.ToArray();
foreach (var source in sources)
{
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
@@ -440,10 +476,6 @@ namespace Emby.Server.Implementations.Library
if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase))
{
originalLanguage = !string.IsNullOrWhiteSpace(originalLanguage)
? originalLanguage.Split(',').FirstOrDefault()
: null;
if (user.PlayDefaultAudioTrack)
{
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(
@@ -498,17 +530,7 @@ namespace Emby.Server.Implementations.Library
var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections;
var originalLanguage = item?.OriginalLanguage ?? item switch
{
Episode episode => episode.Series.OriginalLanguage,
Video video => video.GetOwner() switch
{
Episode ownerEpisode => ownerEpisode.OriginalLanguage ?? ownerEpisode.Series.OriginalLanguage,
BaseItem owner => owner.OriginalLanguage,
null => null
},
_ => null
};
var originalLanguage = item?.GetInheritedOriginalLanguage();
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage);
SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);
@@ -524,24 +546,32 @@ namespace Emby.Server.Implementations.Library
}
}
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default)
{
return sources.OrderBy(i =>
{
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
// The source belonging to the queried item sorts first so it stays the default that gets played.
var preferredId = preferredItemId.IsEmpty()
? null
: preferredItemId.ToString("N", CultureInfo.InvariantCulture);
return sources
.OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
.ThenBy(i =>
{
return 0;
}
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
{
return 0;
}
return 1;
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
.ThenByDescending(i =>
{
var stream = i.VideoStream;
return 1;
})
.ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
.ThenByDescending(i =>
{
var stream = i.VideoStream;
return stream?.Width ?? 0;
})
.Where(i => i.Type != MediaSourceType.Placeholder);
return stream?.Width ?? 0;
})
.Where(i => i.Type != MediaSourceType.Placeholder);
}
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)

View File

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

View File

@@ -0,0 +1,458 @@
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

@@ -0,0 +1,230 @@
#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

@@ -1,200 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Querying;
using MediaBrowser.Model.Search;
namespace Emby.Server.Implementations.Library
{
public class SearchEngine : ISearchEngine
{
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
public SearchEngine(ILibraryManager libraryManager, IUserManager userManager)
{
_libraryManager = libraryManager;
_userManager = userManager;
}
public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query)
{
User? user = null;
if (!query.UserId.IsEmpty())
{
user = _userManager.GetUserById(query.UserId);
}
var results = GetSearchHints(query, user);
var totalRecordCount = results.Count;
if (query.StartIndex.HasValue)
{
results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value);
}
if (query.Limit.HasValue && query.Limit.Value > 0)
{
results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count));
}
return new QueryResult<SearchHintInfo>(
query.StartIndex,
totalRecordCount,
results);
}
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
{
if (!list.Contains(value))
{
list.Add(value);
}
}
/// <summary>
/// Gets the search hints.
/// </summary>
/// <param name="query">The query.</param>
/// <param name="user">The user.</param>
/// <returns>IEnumerable{SearchHintResult}.</returns>
/// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception>
private List<SearchHintInfo> GetSearchHints(SearchQuery query, User? user)
{
var searchTerm = query.SearchTerm;
ArgumentException.ThrowIfNullOrEmpty(searchTerm);
searchTerm = searchTerm.Trim().RemoveDiacritics();
var excludeItemTypes = query.ExcludeItemTypes.ToList();
var includeItemTypes = query.IncludeItemTypes.ToList();
excludeItemTypes.Add(BaseItemKind.Year);
excludeItemTypes.Add(BaseItemKind.Folder);
if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre)))
{
if (!query.IncludeMedia)
{
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
}
}
else
{
AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
}
if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person)))
{
if (!query.IncludeMedia)
{
AddIfMissing(includeItemTypes, BaseItemKind.Person);
}
}
else
{
AddIfMissing(excludeItemTypes, BaseItemKind.Person);
}
if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio)))
{
if (!query.IncludeMedia)
{
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
}
}
else
{
AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
}
if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist)))
{
if (!query.IncludeMedia)
{
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
}
}
else
{
AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
}
AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder);
AddIfMissing(excludeItemTypes, BaseItemKind.Folder);
var mediaTypes = query.MediaTypes.ToList();
if (includeItemTypes.Count > 0)
{
excludeItemTypes.Clear();
mediaTypes.Clear();
}
var searchQuery = new InternalItemsQuery(user)
{
SearchTerm = searchTerm,
ExcludeItemTypes = excludeItemTypes.ToArray(),
IncludeItemTypes = includeItemTypes.ToArray(),
Limit = query.Limit,
IncludeItemsByName = !query.ParentId.HasValue,
ParentId = query.ParentId ?? Guid.Empty,
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
Recursive = true,
IsKids = query.IsKids,
IsMovie = query.IsMovie,
IsNews = query.IsNews,
IsSeries = query.IsSeries,
IsSports = query.IsSports,
MediaTypes = mediaTypes.ToArray(),
DtoOptions = new DtoOptions
{
Fields = new ItemFields[]
{
ItemFields.AirTime,
ItemFields.DateCreated,
ItemFields.ChannelInfo,
ItemFields.ParentId
}
}
};
IReadOnlyList<BaseItem> mediaItems;
if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
{
if (!searchQuery.ParentId.IsEmpty())
{
searchQuery.AncestorIds = [searchQuery.ParentId];
searchQuery.ParentId = Guid.Empty;
}
searchQuery.IncludeItemsByName = true;
searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>();
mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList();
}
else
{
mediaItems = _libraryManager.GetItemList(searchQuery);
}
return mediaItems.Select(i => new SearchHintInfo
{
Item = i
}).ToList();
}
}
}

View File

@@ -1,36 +1,72 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Configuration;
using Microsoft.EntityFrameworkCore;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
namespace Emby.Server.Implementations.Library.SimilarItems;
/// <summary>
/// Provides similar items for movies and trailers.
/// Provides similar items for movies and trailers using weighted scoring.
/// </summary>
public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>
public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, IBatchLocalSimilarItemsProvider
{
private readonly ILibraryManager _libraryManager;
private const int GenreWeight = 10;
private const int TagWeight = 5;
private const int StudioWeight = 5;
private const int DirectorWeight = 50;
private const int ActorWeight = 15;
// Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id
// load, navigation includes) stay bounded regardless of caller input.
private const int MaxBatchSourceItems = 64;
private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions =
[
(ItemValueType.Genre, GenreWeight),
(ItemValueType.Tags, TagWeight),
(ItemValueType.Studios, StudioWeight)
];
private static readonly Dictionary<string, int> _personTypeWeights = new(StringComparer.Ordinal)
{
[nameof(PersonKind.Director)] = DirectorWeight,
[nameof(PersonKind.Actor)] = ActorWeight,
[nameof(PersonKind.GuestStar)] = ActorWeight,
};
private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys];
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IItemQueryHelpers _queryHelpers;
private readonly IServerConfigurationManager _serverConfigurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="dbProvider">The database context factory.</param>
/// <param name="queryHelpers">The shared query helpers.</param>
/// <param name="serverConfigurationManager">The server configuration manager.</param>
public MovieSimilarItemsProvider(
ILibraryManager libraryManager,
IDbContextFactory<JellyfinDbContext> dbProvider,
IItemQueryHelpers queryHelpers,
IServerConfigurationManager serverConfigurationManager)
{
_libraryManager = libraryManager;
_dbProvider = dbProvider;
_queryHelpers = queryHelpers;
_serverConfigurationManager = serverConfigurationManager;
}
@@ -41,15 +77,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
/// <inheritdoc/>
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
{
return Task.FromResult(GetSimilarMovieItems(item, query));
var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
return results.TryGetValue(item.Id, out var items) ? items : [];
}
/// <inheritdoc/>
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
{
return Task.FromResult(GetSimilarMovieItems(item, query));
var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
return results.TryGetValue(item.Id, out var items) ? items : [];
}
bool ILocalSimilarItemsProvider.Supports(Type itemType)
@@ -63,29 +101,233 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
_ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
};
private IReadOnlyList<BaseItem> GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query)
/// <inheritdoc/>
public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
IReadOnlyList<BaseItemDto> sourceItems,
SimilarItemsQuery query,
CancellationToken cancellationToken)
{
var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
{
includeItemTypes.Add(BaseItemKind.Trailer);
includeItemTypes.Add(BaseItemKind.LiveTvProgram);
}
var internalQuery = new InternalItemsQuery(query.User)
{
Genres = item.Genres,
Tags = item.Tags,
Limit = query.Limit,
DtoOptions = query.DtoOptions ?? new DtoOptions(),
ExcludeItemIds = [.. query.ExcludeItemIds],
IncludeItemTypes = [.. includeItemTypes],
EnableGroupByMetadataKey = true,
EnableTotalRecordCount = false,
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
};
var limit = query.Limit ?? 50;
var dtoOptions = query.DtoOptions ?? new DtoOptions();
return _libraryManager.GetItemList(internalQuery);
if (sourceItems.Count > MaxBatchSourceItems)
{
sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList();
}
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
// Phase 1: Score all candidates per source item
var sourceIds = sourceItems.Select(i => i.Id).ToList();
var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false);
var allCandidateIds = new HashSet<Guid>();
foreach (var (_, scores) in perSourceScores)
{
allCandidateIds.UnionWith(
scores.OrderByDescending(kvp => kvp.Value)
.Take(limit * 3)
.Select(kvp => kvp.Key));
}
var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
if (allCandidateIds.Count == 0)
{
return result;
}
// Phase 2: One access filter for all candidates
var filter = new InternalItemsQuery(query.User)
{
IncludeItemTypes = [.. includeItemTypes],
ExcludeItemIds = [.. query.ExcludeItemIds],
DtoOptions = dtoOptions,
EnableGroupByMetadataKey = true,
EnableTotalRecordCount = false,
IsMovie = true,
IsPlayed = false
};
_queryHelpers.PrepareFilterQuery(filter);
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
var allCandidateIdsList = allCandidateIds.ToList();
var accessibleItems = await baseQuery
.WhereOneOrMany(allCandidateIdsList, e => e.Id)
.Select(e => new { e.Id, e.PresentationUniqueKey })
.ToListAsync(cancellationToken).ConfigureAwait(false);
// Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
var allOrderedIds = new HashSet<Guid>();
var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>();
foreach (var item in sourceItems)
{
if (!perSourceScores.TryGetValue(item.Id, out var scores))
{
continue;
}
var orderedIds = accessibleItems
.Where(x => scores.ContainsKey(x.Id))
.OrderByDescending(x => scores.GetValueOrDefault(x.Id))
.DistinctBy(x => x.PresentationUniqueKey)
.Take(limit)
.Select(x => x.Id)
.ToList();
if (orderedIds.Count > 0)
{
perSourceOrderedIds[item.Id] = orderedIds;
allOrderedIds.UnionWith(orderedIds);
}
}
if (allOrderedIds.Count == 0)
{
return result;
}
// Phase 4: One entity load for all results
var allOrderedIdsList = allOrderedIds.ToList();
var entities = await _queryHelpers.ApplyNavigations(
context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id),
filter)
.AsSplitQuery()
.ToListAsync(cancellationToken).ConfigureAwait(false);
var entitiesById = entities
.Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization))
.Where(dto => dto is not null)
.ToDictionary(i => i!.Id);
// Phase 5: Split by source, preserving score order
foreach (var (sourceId, orderedIds) in perSourceOrderedIds)
{
var items = orderedIds
.Where(entitiesById.ContainsKey)
.Select(id => entitiesById[id]!)
.ToList();
if (items.Count > 0)
{
result[sourceId] = items;
}
}
return result;
}
}
private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, JellyfinDbContext context, CancellationToken cancellationToken)
{
var result = new Dictionary<Guid, Dictionary<Guid, int>>();
foreach (var id in sourceIds)
{
result[id] = [];
}
foreach (var (valueType, weight) in _itemValueDimensions)
{
var sourceRows = await context.ItemValuesMap.AsNoTracking()
.Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType)
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
.ToListAsync(cancellationToken).ConfigureAwait(false);
var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet());
var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList();
if (allKeys.Count == 0)
{
continue;
}
var candidateRows = await context.ItemValuesMap.AsNoTracking()
.Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
.ToListAsync(cancellationToken).ConfigureAwait(false);
var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result);
}
var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking()
.Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType))
.Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType })
.ToListAsync(cancellationToken).ConfigureAwait(false);
if (personSourceRows.Count > 0)
{
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
.Where(m => context.PeopleBaseItemMap
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
.Select(s => s.PeopleId)
.Contains(m.PeopleId))
.Select(m => new { m.ItemId, m.PeopleId })
.ToListAsync(cancellationToken).ConfigureAwait(false);
var personToCandidates = personCandidateRows
.GroupBy(r => r.PeopleId)
.ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!]))
{
var sourceMap = weightGroup
.GroupBy(r => r.ItemId)
.ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet());
ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result);
}
}
foreach (var sourceId in sourceIds)
{
var scoreMap = result[sourceId];
scoreMap.Remove(sourceId);
if (scoreMap.Count == 0)
{
result.Remove(sourceId);
}
}
return result;
}
private static void ApplyDimensionScores<TKey>(
List<Guid> sourceIds,
Dictionary<Guid, HashSet<TKey>> sourceMap,
Dictionary<TKey, List<Guid>> keyToCandidates,
int weight,
Dictionary<Guid, Dictionary<Guid, int>> result)
where TKey : notnull
{
foreach (var sourceId in sourceIds)
{
if (!sourceMap.TryGetValue(sourceId, out var sourceKeys))
{
continue;
}
var scoreMap = result[sourceId];
foreach (var key in sourceKeys)
{
if (!keyToCandidates.TryGetValue(key, out var candidates))
{
continue;
}
foreach (var candidateId in candidates)
{
scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight;
}
}
}
}
}

View File

@@ -8,12 +8,16 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
@@ -30,6 +34,7 @@ public class SimilarItemsManager : ISimilarItemsManager
private readonly IServerApplicationPaths _appPaths;
private readonly ILibraryManager _libraryManager;
private readonly IFileSystem _fileSystem;
private readonly IServerConfigurationManager _serverConfigurationManager;
private ISimilarItemsProvider[] _similarItemsProviders = [];
/// <summary>
@@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager
/// <param name="appPaths">The server application paths.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="fileSystem">The file system.</param>
/// <param name="serverConfigurationManager">The server configuration manager.</param>
public SimilarItemsManager(
ILogger<SimilarItemsManager> logger,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager,
IFileSystem fileSystem)
IFileSystem fileSystem,
IServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_appPaths = appPaths;
_libraryManager = libraryManager;
_fileSystem = fileSystem;
_serverConfigurationManager = serverConfigurationManager;
}
/// <inheritdoc/>
@@ -117,6 +125,7 @@ public class SimilarItemsManager : ISimilarItemsManager
var allResults = new List<(BaseItem Item, float Score)>();
var excludeIds = new HashSet<Guid> { item.Id };
var excludeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() };
foreach (var (providerOrder, provider) in orderedProviders.Index())
{
if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
@@ -141,7 +150,9 @@ public class SimilarItemsManager : ISimilarItemsManager
foreach (var (position, resultItem) in items.Index())
{
if (excludeIds.Add(resultItem.Id))
var isNewId = excludeIds.Add(resultItem.Id);
var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey());
if (isNewId && isNewKey)
{
var score = CalculateScore(null, providerOrder, position);
allResults.Add((resultItem, score));
@@ -155,7 +166,7 @@ public class SimilarItemsManager : ISimilarItemsManager
var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false);
if (cachedReferences is not null)
{
var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds);
var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
continue;
}
@@ -183,7 +194,7 @@ public class SimilarItemsManager : ISimilarItemsManager
if (pendingBatch.Count >= BatchSize)
{
var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds);
var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
remaining -= resolvedItems.Count;
pendingBatch.Clear();
@@ -198,7 +209,7 @@ public class SimilarItemsManager : ISimilarItemsManager
// Resolve any remaining references in the last partial batch
if (pendingBatch.Count > 0)
{
var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds);
var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
}
@@ -225,20 +236,230 @@ public class SimilarItemsManager : ISimilarItemsManager
.ToList();
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
User? user,
Guid parentId,
int categoryLimit,
int itemLimit,
DtoOptions dtoOptions,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(dtoOptions);
var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
{
IncludeItemTypes = [BaseItemKind.Movie],
OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)],
Limit = 7,
ParentId = parentId,
Recursive = true,
IsPlayed = true,
EnableGroupByMetadataKey = true,
DtoOptions = dtoOptions
});
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
{
itemTypes.Add(BaseItemKind.Trailer);
itemTypes.Add(BaseItemKind.LiveTvProgram);
}
var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
{
IncludeItemTypes = itemTypes.ToArray(),
IsMovie = true,
OrderBy = [(ItemSortBy.Random, SortOrder.Descending)],
Limit = 10,
IsFavoriteOrLiked = true,
ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
EnableGroupByMetadataKey = true,
ParentId = parentId,
Recursive = true,
DtoOptions = dtoOptions
});
var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]);
var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]);
// Cap baseline items to categoryLimit - the round-robin can't use more categories than that.
var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit
? recentlyPlayedMovies.Take(categoryLimit).ToList()
: recentlyPlayedMovies;
var likedBaseline = likedMovies.Count > categoryLimit
? likedMovies.Take(categoryLimit).ToList()
: likedMovies;
var batchQuery = new SimilarItemsQuery
{
User = user,
Limit = itemLimit,
DtoOptions = dtoOptions
};
var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync(
recentlyPlayedBaseline,
RecommendationType.SimilarToRecentlyPlayed,
batchQuery,
cancellationToken).ConfigureAwait(false);
var similarToLiked = await GetSimilarItemsRecommendationsAsync(
likedBaseline,
RecommendationType.SimilarToLikedItem,
batchQuery,
cancellationToken).ConfigureAwait(false);
var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes);
var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes);
// Use a single enumerator per list, listed twice so MoveNext advances it
// twice per round-robin pass (giving these categories double weight).
// IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once;
// using var would box separately per list insertion, creating independent copies.
IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>>
{
similarToRecentlyPlayedEnum,
similarToRecentlyPlayedEnum,
similarToLikedEnum,
similarToLikedEnum,
hasDirectorFromRecentlyPlayed.GetEnumerator(),
hasActorFromRecentlyPlayed.GetEnumerator()
};
var categories = new List<SimilarItemsRecommendation>();
while (categories.Count < categoryLimit)
{
var allEmpty = true;
foreach (var category in categoryTypes)
{
if (category.MoveNext())
{
categories.Add(category.Current);
allEmpty = false;
if (categories.Count >= categoryLimit)
{
break;
}
}
}
if (allEmpty)
{
break;
}
}
return [.. categories.OrderBy(i => i.RecommendationType)];
}
private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync(
IReadOnlyList<BaseItem> baselineItems,
RecommendationType recommendationType,
SimilarItemsQuery query,
CancellationToken cancellationToken)
{
var batchProvider = _similarItemsProviders
.OfType<IBatchLocalSimilarItemsProvider>()
.FirstOrDefault();
if (batchProvider is null || baselineItems.Count == 0)
{
return [];
}
var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
foreach (var baseline in baselineItems)
{
if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
{
recommendations.Add(new SimilarItemsRecommendation
{
BaselineItemName = baseline.Name,
CategoryId = baseline.Id,
RecommendationType = recommendationType,
Items = similar
});
}
}
return recommendations;
}
private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations(
User? user,
IReadOnlyList<string> names,
int itemLimit,
DtoOptions dtoOptions,
RecommendationType type,
IReadOnlyList<BaseItemKind> itemTypes)
{
var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
? [PersonType.Director]
: Array.Empty<string>();
foreach (var name in names)
{
var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
{
Person = name,
Limit = itemLimit + 2,
PersonTypes = personTypes,
IncludeItemTypes = itemTypes.ToArray(),
IsMovie = true,
IsPlayed = false,
EnableGroupByMetadataKey = true,
DtoOptions = dtoOptions
})
.DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
.Take(itemLimit)
.ToList();
if (items.Count > 0)
{
yield return new SimilarItemsRecommendation
{
BaselineItemName = name,
CategoryId = name.GetMD5(),
RecommendationType = type,
Items = items
};
}
}
}
private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes)
{
var itemIds = items.Select(i => i.Id).ToArray();
return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes)
.Values
.SelectMany(names => names)
.Distinct()
.ToArray();
}
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
IReadOnlyList<SimilarItemReference> references,
int providerOrder,
User? user,
DtoOptions dtoOptions,
BaseItemKind itemKind,
HashSet<Guid> excludeIds)
HashSet<Guid> excludeIds,
HashSet<string> excludeKeys)
{
if (references.Count == 0)
{
return [];
}
var resolvedById = new Dictionary<Guid, (BaseItem Item, float Score)>();
var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase);
var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance);
foreach (var (position, match) in references.Index())
@@ -269,7 +490,13 @@ public class SimilarItemsManager : ISimilarItemsManager
foreach (var item in items)
{
if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id))
if (excludeIds.Contains(item.Id))
{
continue;
}
var presentationKey = item.GetPresentationUniqueKey();
if (excludeKeys.Contains(presentationKey))
{
continue;
}
@@ -279,10 +506,9 @@ public class SimilarItemsManager : ISimilarItemsManager
if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo))
{
var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score)
if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score)
{
excludeIds.Add(item.Id);
resolvedById[item.Id] = (item, score);
resolvedByKey[presentationKey] = (item, score);
}
break;
@@ -290,7 +516,13 @@ public class SimilarItemsManager : ISimilarItemsManager
}
}
return [.. resolvedById.Values];
foreach (var (key, entry) in resolvedByKey)
{
excludeIds.Add(entry.Item.Id);
excludeKeys.Add(key);
}
return [.. resolvedByKey.Values];
}
private static float CalculateScore(float? matchScore, int providerOrder, int position)

View File

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

View File

@@ -106,5 +106,7 @@
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
"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,5 +106,7 @@
"TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
"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.",
"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

@@ -108,5 +108,5 @@
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.",
"Original": "Original",
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}."
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}"
}

View File

@@ -16,5 +16,97 @@
"HeaderLiveTV": "טלוויזיה בשידור חי",
"HeaderNextUp": "הבא",
"HearingImpaired": "ללקויי שמיעה",
"HomeVideos": "סרטונים ביתיים"
"HomeVideos": "סרטונים ביתיים",
"AppDeviceValues": "אפליקציה: {0}, מכשיר: {1}",
"AuthenticationSucceededWithUserName": "{0} אומת בהצלחה",
"Default": "בררת מחדל",
"FailedLoginAttemptWithUserName": "התחברות נכשלה מ {0}",
"Forced": "בכוח",
"Inherit": "ירש",
"LabelIpAddressValue": "כתובת IP: {0}",
"LabelRunningTimeValue": "זמן ריצה: {0}",
"Latest": "הכי חדש",
"LyricDownloadFailureFromForItem": "מילות שיר נכשלו לרדת מ{0} בשביל {1}",
"MixedContent": "תוכן מעורב",
"MusicVideos": "סרטוני מוזיקה",
"NameInstallFailed": "{0} התכנות כושלות",
"NameSeasonUnknown": "עונה לא ידוע",
"NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.",
"NotificationOptionApplicationUpdateAvailable": "גרסת אפליקציה חדשה זמינה להורדה",
"NotificationOptionApplicationUpdateInstalled": "עדכון אפליקציה הותקן",
"NotificationOptionAudioPlayback": "החלה השמעת אודיו",
"NotificationOptionAudioPlaybackStopped": "ניגון השמע הופסק",
"NotificationOptionCameraImageUploaded": "תמונת מצלמה עודכן",
"NotificationOptionInstallationFailed": "התקנה נכשלה",
"NotificationOptionNewLibraryContent": "תוכן חדש נוסף",
"NotificationOptionPluginError": "תוסף נכשל",
"NotificationOptionPluginInstalled": "תוסף הותקן",
"NotificationOptionPluginUninstalled": "תוסף נמחק",
"NotificationOptionPluginUpdateInstalled": "עידכון לתוסף הותקן",
"NotificationOptionServerRestartRequired": "נדרש התחול מחדש לשרת",
"NotificationOptionTaskFailed": "כשל במשימה מתוכננת",
"NotificationOptionUserLockedOut": "המשתמש ננעל",
"NotificationOptionVideoPlayback": "החלה הפעלת וידאו",
"NotificationOptionVideoPlaybackStopped": "הפעלת הסרטון הופסקה",
"Original": "מקורי",
"Photos": "תמונות",
"PluginInstalledWithName": "{0} הותקן",
"PluginUninstalledWithName": "{0} נמחק",
"PluginUpdatedWithName": "{0} עודכן",
"ScheduledTaskFailedWithName": "{0} נכשל",
"Shows": "סדרות",
"StartupEmbyServerIsLoading": "שרת Jellyfin נטען. אנא נסה שוב בקרוב.",
"SubtitleDownloadFailureFromForItem": "הורדת הכתוביות מ-{0} עבור {1} נכשלה",
"TvShows": "תוכניות טלויזיה",
"Undefined": "לא מוגדר",
"UserCreatedWithName": "המשתמש {0} נוצר",
"UserDeletedWithName": "המשתמש {0} נמחק",
"UserDownloadingItemWithValues": "{0} מוריד את {1}",
"UserLockedOutWithName": "המשתמש {0} ננעל בחוץ",
"UserOfflineFromDevice": "{0} התנתק מ-{1}",
"UserOnlineFromDevice": "{0} מחובר מ-{1}",
"UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}",
"UserStartedPlayingItemWithValues": "{0} מנגן ב-{1} ב-{2}",
"UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} ב-{2}",
"VersionNumber": "גרסה {0}",
"TasksMaintenanceCategory": "תחזוקה",
"TasksLibraryCategory": "ספריה",
"TasksApplicationCategory": "אפליקציה",
"TasksChannelsCategory": "ערוצי אינטרנט",
"TaskCleanActivityLog": "נקה יומן פעילות",
"TaskCleanActivityLogDescription": "מוחק רשומות יומן פעילות ישנות יותר מהגיל שהוגדר.",
"TaskCleanCache": "נקה ספריית מטמון",
"TaskCleanCacheDescription": "מוחק קבצי מטמון שאינם נחוצים עוד על ידי המערכת.",
"TaskRefreshChapterImages": "חלץ תמונות פרק",
"TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות עבור סרטונים שיש להם פרקים.",
"TaskAudioNormalization": "נורמליזציה של שמע",
"TaskAudioNormalizationDescription": "סורק קבצים לאיתור נתוני נרמול שמע.",
"TaskRefreshLibrary": "סרוק ספריית מדיה",
"TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך לאיתור קבצים חדשים ומרענן מטא-דאטה.",
"TaskCleanLogs": "נקה ספריית יומן",
"TaskCleanLogsDescription": "מוחק קבצי יומן שגילם עולה על {0} ימים.",
"TaskRefreshPeople": "רענן אנשים",
"TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.",
"TaskRefreshTrickplayImages": "צור תמונות Trickplay",
"TaskRefreshTrickplayImagesDescription": "יוצר תצוגות מקדימות של trickplay עבור סרטונים בספריות מופעלות.",
"TaskUpdatePlugins": "עדכן פלאגינים",
"TaskUpdatePluginsDescription": "מוריד ומתקין עדכונים עבור תוספים שתצורתם נקבעה לעדכון אוטומטי.",
"TaskCleanTranscode": "נקה ספריית קידוד",
"TaskCleanTranscodeDescription": "תמחוק את קבצי הקידוד בני יותר מיום.",
"TaskRefreshChannels": "רענן ערוצים",
"TaskRefreshChannelsDescription": "מרענן את פרטי ערוץ האינטרנט.",
"TaskDownloadMissingLyrics": "הורד מילות שיר חסרות",
"TaskDownloadMissingLyricsDescription": "הורדות מילים לשירים",
"TaskDownloadMissingSubtitles": "הורד כתוביות חסרות",
"TaskDownloadMissingSubtitlesDescription": "מחפש באינטרנט אחר כתוביות חסרות בהתבסס על תצורת מטא-דאטה.",
"TaskOptimizeDatabase": "בצע אופטימיזציה של מסד הנתונים",
"TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים וקיצוץ שטח פנוי. הפעלת משימה זו לאחר סריקת הספרייה או ביצוע שינויים אחרים שמשמעותם שינויים בבסיס הנתונים עשויה לשפר את הביצועים.",
"TaskKeyframeExtractor": "מחלץ פריים מרכזי",
"TaskKeyframeExtractorDescription": "מחלץ פריימים מרכזיים מקבצי וידאו כדי ליצור רשימות השמעה HLS מדויקות יותר. משימה זו עשויה להימשך זמן רב.",
"TaskExtractMediaSegments": "סריקת מקטעי מדיה",
"TaskExtractMediaSegmentsDescription": "מחלץ או משיג קטעי מדיה מתוספים התומכים ב-MediaSegment.",
"TaskMoveTrickplayImages": "העברת מיקום תמונת Trickplay",
"TaskMoveTrickplayImagesDescription": "מעביר קבצי trickplay קיימים בהתאם להגדרות הספרייה.",
"CleanupUserDataTask": "משימת ניקוי נתוני משתמש",
"CleanupUserDataTaskDescription": "מנקה את כל נתוני המשתמש (מצב צפייה, סטטוס מועדף וכו') ממדיה שכבר לא הייתה קיימת במשך 90 יום לפחות."
}

View File

@@ -106,5 +106,7 @@
"TaskExtractMediaSegments": "Scan Segmen media",
"TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay",
"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

@@ -20,7 +20,7 @@
"External": "გარე",
"HeaderFavoriteEpisodes": "რჩეული ეპიზოდები",
"HearingImpaired": "სმენადაქვეითებული",
"LabelRunningTimeValue": "ხანგრძლივობა: {0}",
"LabelRunningTimeValue": "გაშვების დრო: {0}",
"MixedContent": "შერეული შემცველობა",
"MusicVideos": "მუსიკის ვიდეოები",
"NotificationOptionInstallationFailed": "დაყენების შეცდომა",
@@ -31,7 +31,7 @@
"PluginUninstalledWithName": "{0} წაიშალა",
"VersionNumber": "ვერსია {0}",
"TasksChannelsCategory": "ინტერნეტ-არხები",
"TaskRefreshChannelsDescription": "ინტერნეტ-არხის ინფორმაციის განახლება.",
"TaskRefreshChannelsDescription": "განაახლებს ინტერნეტ-არხის ინფორმაცია.",
"Collections": "კოლექციები",
"Default": "ნაგულისხმევი",
"Favorites": "რჩეულები",
@@ -53,32 +53,32 @@
"TaskOptimizeDatabase": "მონაცემთა ბაზის ოპტიმიზაცია",
"TaskKeyframeExtractor": "საკვანძო კადრის გამომღები",
"LabelIpAddressValue": "IP მისამართი: {0}",
"NameInstallFailed": "{0}-ის დაყენების შეცდომა",
"NameInstallFailed": "{0}-ის დაყენების ჩავარდა",
"NotificationOptionApplicationUpdateAvailable": "ხელმისაწვდომია აპლიკაციის განახლება",
"NotificationOptionAudioPlaybackStopped": "აუდიოს დაკვრა გაჩერებულია",
"NotificationOptionNewLibraryContent": "ახალი შემცველობა დამატებულია",
"NotificationOptionPluginUpdateInstalled": "მოდულიs განახლება დაყენებულია",
"NotificationOptionPluginUpdateInstalled": "დამატების განახლება დაყენებულია",
"NotificationOptionServerRestartRequired": "საჭიროა სერვერის გადატვირთვა",
"NotificationOptionTaskFailed": "გეგმიური დავალების შეცდომა",
"NotificationOptionTaskFailed": "დაგეგმილი ამოცანა ჩავარდა",
"NotificationOptionUserLockedOut": "მომხმარებელი დაიბლოკა",
"NotificationOptionVideoPlayback": "ვიდეოს დაკვრა დაწყებულია",
"PluginInstalledWithName": "{0} დაყენებულია",
"PluginUpdatedWithName": "{0} განახლდა",
"TaskCleanActivityLog": "აქტივობების ჟურნალის გასუფთავება",
"TaskCleanCache": "ეშის საქაღალდის გასუფთავება",
"TaskRefreshChapterImages": "თავის სურათების გაშლა",
"TaskCleanCache": "ეშის საქაღალდის გასუფთავება",
"TaskRefreshChapterImages": "თავის სურათების ამოღება",
"TaskRefreshLibrary": "მედიის ბიბლიოთეკის სკანირება",
"TaskCleanLogs": "ჟურნალის საქაღალდის გასუფთავება",
"TaskCleanTranscode": "ტრანსკოდირების საქაღალდის გასუფთავება",
"TaskDownloadMissingSubtitles": "მიუწვდომელი სუბტიტრების გადმოწერა",
"UserDownloadingItemWithValues": "{0} -ი {1}-ს იწერს",
"TaskDownloadMissingSubtitles": "ნაკლული სუბტიტრების გადმოწერა",
"UserDownloadingItemWithValues": "{0} იწერს {1}-ს",
"FailedLoginAttemptWithUserName": "შესვლის წარუმატებელი მცდელობა {0}-დან",
"UserCreatedWithName": "მომხმარებელი {0} შეიქმნა",
"UserDeletedWithName": "მომხმარებელი {0} წაშლილია",
"UserOnlineFromDevice": "{0}-ი დაკავშირდა {1}-დან",
"UserOfflineFromDevice": "{0}-ი {1}-დან გაეთიშა",
"UserDeletedWithName": "მომხმარებელი {0} წაიშალა",
"UserOnlineFromDevice": "{0} ხაზზეა {1}-დან",
"UserOfflineFromDevice": "{0} გაითიშა {1}-დან",
"UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია",
"UserStartedPlayingItemWithValues": "{0} უყურებს {1}-ს {2}-ზე",
"UserStartedPlayingItemWithValues": "{0} უკრავს {1}-ს {2}-ზე",
"UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა",
"UserStoppedPlayingItemWithValues": "{0}-მა დაასრულა {1}-ის ყურება {2}-ზე",
"TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა.",
@@ -96,16 +96,16 @@
"TaskDownloadMissingSubtitlesDescription": "ეძებს ბიბლიოთეკაში მიუწვდომელ სუბტიტრებს ინტერნეტში მეტამონაცემებზე დაყრდნობით.",
"TaskOptimizeDatabaseDescription": "კუმშავს მონაცემთა ბაზას ადგილის გათავისუფლებლად. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს.",
"TaskRefreshTrickplayImagesDescription": "ქმნის trickplay წინასწარ ხედებს ვიდეოებისთვის დაშვებულ ბიბლიოთეკებში.",
"TaskRefreshTrickplayImages": "Trickplay სურათების გენერირება",
"TaskAudioNormalization": "აუდიოს ნორმალიზება",
"TaskRefreshTrickplayImages": "Trickplay სურათების გენერაცია",
"TaskAudioNormalization": "აუდიოს ნორმალიზაცია",
"TaskAudioNormalizationDescription": "აანალიზებს ფაილებს აუდიოს ნორმალიზაციისთვის.",
"TaskDownloadMissingLyrics": "მიუწვდომელი ლირიკების ჩამოტვირთვა",
"TaskDownloadMissingLyricsDescription": "ჩამოტვირთავს ამჟამად ბიბლიოთეკაში არარსებულ ლირიკებს სიმღერებისთვის",
"TaskExtractMediaSegments": "მედი სეგმენტების სკანირება",
"TaskDownloadMissingLyricsDescription": "გადმოწერს ლირიკს სიმღერებისთვის",
"TaskExtractMediaSegments": "მედიის სეგმენტების სკანირება",
"TaskExtractMediaSegmentsDescription": "მედია სეგმენტების სკანირება მხარდაჭერილი მოდულებისთვის.",
"TaskMoveTrickplayImages": "Trickplay სურათების მიგრაცია",
"TaskMoveTrickplayImages": "Trickplay-ის გამოსახულებების მდებარეობის მიგრაცია",
"TaskMoveTrickplayImagesDescription": "გადააქვს trickplay ფაილები ბიბლიოთეკის პარამეტრებზე დაყრდნობით.",
"CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავება",
"CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავების ამოცანა",
"CleanupUserDataTaskDescription": "ასუფთავებს მომხმარებლების მონაცემებს (ყურების სტატუსი, ფავორიტები ანდ ა.შ) მედია ელემენტებისთვის რომლების 90 დღეზე მეტია აღარ არსებობენ.",
"LyricDownloadFailureFromForItem": "{1}-ისთვის {0}-დან ლირიკის გადმოწერა ჩავარდა",
"Original": "ორიგინალი"

View File

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

View File

@@ -8,7 +8,7 @@
"FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}",
"Favorites": "Favorieten",
"Folders": "Mappen",
"HeaderContinueWatching": "Verder kijken",
"HeaderContinueWatching": "Verderkijken",
"HeaderFavoriteEpisodes": "Favoriete afleveringen",
"HeaderFavoriteShows": "Favoriete series",
"HeaderLiveTV": "Live-tv",

View File

@@ -1 +1,3 @@
{}
{
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -453,18 +453,6 @@ namespace Emby.Server.Implementations.Session
session.PlayState.RepeatMode = info.RepeatMode;
session.PlayState.PlaybackOrder = info.PlaybackOrder;
session.PlaylistItemId = info.PlaylistItemId;
var nowPlayingQueue = info.NowPlayingQueue;
if (nowPlayingQueue?.Length > 0 && !nowPlayingQueue.SequenceEqual(session.NowPlayingQueue))
{
session.NowPlayingQueue = nowPlayingQueue;
var itemIds = Array.ConvertAll(nowPlayingQueue, queue => queue.Id);
session.NowPlayingQueueFullItems = _dtoService.GetBaseItemDtos(
_libraryManager.GetItemList(new InternalItemsQuery { ItemIds = itemIds }),
new DtoOptions(true));
}
}
/// <summary>
@@ -1217,7 +1205,6 @@ namespace Emby.Server.Implementations.Session
SupportsMediaControl = sessionInfo.SupportsMediaControl,
SupportsRemoteControl = sessionInfo.SupportsRemoteControl,
NowPlayingQueue = sessionInfo.NowPlayingQueue,
NowPlayingQueueFullItems = sessionInfo.NowPlayingQueueFullItems,
HasCustomDeviceName = sessionInfo.HasCustomDeviceName,
PlaylistItemId = sessionInfo.PlaylistItemId,
ServerId = sessionInfo.ServerId,

View File

@@ -527,42 +527,44 @@ namespace Emby.Server.Implementations.Updates
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
// CA5351: Do Not Use Broken Cryptographic Algorithms
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
// CA5351: Do Not Use Broken Cryptographic Algorithms
#pragma warning disable CA5351
cancellationToken.ThrowIfCancellationRequested();
cancellationToken.ThrowIfCancellationRequested();
var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
package.Name,
package.Checksum,
hash);
throw new InvalidDataException("The checksum of the received data doesn't match.");
}
// Version folder as they cannot be overwritten in Windows.
targetDir += "_" + package.Version;
if (Directory.Exists(targetDir))
{
try
var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
{
Directory.Delete(targetDir, true);
_logger.LogError(
"The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
package.Name,
package.Checksum,
hash);
throw new InvalidDataException("The checksum of the received data doesn't match.");
}
// Version folder as they cannot be overwritten in Windows.
targetDir += "_" + package.Version;
if (Directory.Exists(targetDir))
{
try
{
Directory.Delete(targetDir, true);
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
catch
#pragma warning restore CA1031 // Do not catch general exception types
{
// Ignore any exceptions.
{
// Ignore any exceptions.
}
}
}
stream.Position = 0;
await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken);
stream.Position = 0;
await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken).ConfigureAwait(false);
}
// Ensure we create one or populate existing ones with missing data.
await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false);

View File

@@ -88,7 +88,7 @@ public class CollectionController : BaseJellyfinApiController
[FromRoute, Required] Guid collectionId,
[FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids)
{
await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(false);
return NoContent();
}

View File

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

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@@ -42,6 +43,7 @@ public class ItemsController : BaseJellyfinApiController
private readonly ILogger<ItemsController> _logger;
private readonly ISessionManager _sessionManager;
private readonly IUserDataManager _userDataRepository;
private readonly ISearchManager _searchManager;
/// <summary>
/// Initializes a new instance of the <see cref="ItemsController"/> class.
@@ -53,6 +55,7 @@ public class ItemsController : BaseJellyfinApiController
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="searchManager">Instance of the <see cref="ISearchManager"/> interface.</param>
public ItemsController(
IUserManager userManager,
ILibraryManager libraryManager,
@@ -60,7 +63,8 @@ public class ItemsController : BaseJellyfinApiController
IDtoService dtoService,
ILogger<ItemsController> logger,
ISessionManager sessionManager,
IUserDataManager userDataRepository)
IUserDataManager userDataRepository,
ISearchManager searchManager)
{
_userManager = userManager;
_libraryManager = libraryManager;
@@ -69,6 +73,7 @@ public class ItemsController : BaseJellyfinApiController
_logger = logger;
_sessionManager = sessionManager;
_userDataRepository = userDataRepository;
_searchManager = searchManager;
}
/// <summary>
@@ -314,13 +319,10 @@ public class ItemsController : BaseJellyfinApiController
if (collectionType == CollectionType.playlists)
{
recursive = true;
includeItemTypes = new[] { BaseItemKind.Playlist };
includeItemTypes = [BaseItemKind.Playlist];
}
else if (folder is ICollectionFolder)
{
// 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;
if (includeItemTypes.Length == 0)
{
includeItemTypes = collectionType switch
@@ -330,6 +332,13 @@ public class ItemsController : BaseJellyfinApiController
_ => []
};
}
// When the client doesn't specify recursive/includeItemTypes, force the query
// through the database path where all filters (IsHD, genres, etc.) are applied.
if (includeItemTypes.Length > 0)
{
recursive ??= true;
}
}
if (item is not UserRootFolder
@@ -344,6 +353,34 @@ public class ItemsController : BaseJellyfinApiController
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
{
SearchTerm = searchTerm,
UserId = userId,
IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = excludeItemTypes,
MediaTypes = mediaTypes,
Limit = limit.HasValue ? limit.Value * 3 : null,
ParentId = parentId
};
var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false);
if (searchResults.Count > 0)
{
searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score);
itemIds = ids.Length > 0
? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
: searchResultScores.Keys.ToArray();
}
}
var query = new InternalItemsQuery(user)
{
IsPlayed = isPlayed,
@@ -353,8 +390,8 @@ public class ItemsController : BaseJellyfinApiController
Recursive = recursive ?? false,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
IsFavorite = isFavorite,
Limit = limit,
StartIndex = startIndex,
Limit = searchResultScores is null ? limit : null,
StartIndex = searchResultScores is null ? startIndex : null,
IsMissing = isMissing,
IsUnaired = isUnaired,
CollapseBoxSetItems = collapseBoxSetItems,
@@ -401,7 +438,7 @@ public class ItemsController : BaseJellyfinApiController
ImageTypes = imageTypes,
VideoTypes = videoTypes,
AdjacentTo = adjacentTo,
ItemIds = ids,
ItemIds = itemIds,
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
ParentId = parentId ?? Guid.Empty,
@@ -410,7 +447,7 @@ public class ItemsController : BaseJellyfinApiController
EnableTotalRecordCount = enableTotalRecordCount,
ExcludeItemIds = excludeItemIds,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
SearchTerm = searchResultScores is null ? searchTerm : null,
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
MinPremiereDate = minPremiereDate?.ToUniversalTime(),
@@ -522,7 +559,7 @@ public class ItemsController : BaseJellyfinApiController
{
query.AlbumIds = albums.SelectMany(i =>
{
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 });
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
}).ToArray();
}
@@ -548,12 +585,37 @@ public class ItemsController : BaseJellyfinApiController
// 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.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
}
}
query.Parent = null;
// folder.GetItems applies user-access filtering via the InternalItemsQuery's User.
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
{
@@ -909,7 +971,7 @@ public class ItemsController : BaseJellyfinApiController
var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
{
OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)],
IsResumable = true,
StartIndex = startIndex,
Limit = limit,
@@ -919,6 +981,7 @@ public class ItemsController : BaseJellyfinApiController
MediaTypes = mediaTypes,
IsVirtualItem = false,
CollapseBoxSetItems = false,
IncludeOwnedItems = true,
EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds,
IncludeItemTypes = includeItemTypes,

View File

@@ -17,6 +17,7 @@ using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -50,6 +51,7 @@ public class LibraryController : BaseJellyfinApiController
private readonly ISimilarItemsManager _similarItemsManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly ICollectionManager _collectionManager;
private readonly IDtoService _dtoService;
private readonly IActivityManager _activityManager;
private readonly ILocalizationManager _localization;
@@ -64,6 +66,7 @@ public class LibraryController : BaseJellyfinApiController
/// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="collectionManager">Instance of the <see cref="ICollectionManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
@@ -75,6 +78,7 @@ public class LibraryController : BaseJellyfinApiController
ISimilarItemsManager similarItemsManager,
ILibraryManager libraryManager,
IUserManager userManager,
ICollectionManager collectionManager,
IDtoService dtoService,
IActivityManager activityManager,
ILocalizationManager localization,
@@ -86,6 +90,7 @@ public class LibraryController : BaseJellyfinApiController
_similarItemsManager = similarItemsManager;
_libraryManager = libraryManager;
_userManager = userManager;
_collectionManager = collectionManager;
_dtoService = dtoService;
_activityManager = activityManager;
_localization = localization;
@@ -114,7 +119,18 @@ public class LibraryController : BaseJellyfinApiController
return NotFound();
}
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true);
var filePath = item.Path;
if (item.IsFileProtocol)
{
// PhysicalFile does not work well with symlinks at the moment.
var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
if (resolved is not null && resolved.Exists)
{
filePath = resolved.FullName;
}
}
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), true);
}
/// <summary>
@@ -704,6 +720,72 @@ public class LibraryController : BaseJellyfinApiController
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
}
/// <summary>
/// Gets the collections that include the specified item.
/// </summary>
/// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="startIndex">Optional. The index of the first record in the output.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
/// <response code="200">Collections returned.</response>
/// <response code="401">User context missing.</response>
/// <response code="404">Item not found.</response>
/// <returns>The collections that contain the requested item.</returns>
[HttpGet("Items/{itemId}/Collections")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetItemCollections(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields)
{
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
if (user is null)
{
return Unauthorized();
}
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
var dtoOptions = new DtoOptions { Fields = fields };
var visibleCollections = _collectionManager
.GetCollectionsContainingItem(user, item.Id)
.OrderBy(i => i.SortName, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
IEnumerable<BaseItem> pagedCollections = visibleCollections;
if (startIndex.HasValue)
{
pagedCollections = pagedCollections.Skip(startIndex.Value);
}
if (limit.HasValue)
{
pagedCollections = pagedCollections.Take(limit.Value);
}
var dtos = _dtoService.GetBaseItemDtos(pagedCollections.ToList(), dtoOptions, user);
return new QueryResult<BaseItemDto>(
startIndex,
visibleCollections.Count,
dtos);
}
/// <summary>
/// Gets similar items.
/// </summary>

View File

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

View File

@@ -1,17 +1,13 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
@@ -30,27 +26,23 @@ namespace Jellyfin.Api.Controllers;
public class MoviesController : BaseJellyfinApiController
{
private readonly IUserManager _userManager;
private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly ISimilarItemsManager _similarItemsManager;
/// <summary>
/// Initializes a new instance of the <see cref="MoviesController"/> class.
/// </summary>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param>
public MoviesController(
IUserManager userManager,
ILibraryManager libraryManager,
IDtoService dtoService,
IServerConfigurationManager serverConfigurationManager)
ISimilarItemsManager similarItemsManager)
{
_userManager = userManager;
_libraryManager = libraryManager;
_dtoService = dtoService;
_serverConfigurationManager = serverConfigurationManager;
_similarItemsManager = similarItemsManager;
}
/// <summary>
@@ -61,15 +53,17 @@ public class MoviesController : BaseJellyfinApiController
/// <param name="fields">Optional. The fields to return.</param>
/// <param name="categoryLimit">The max number of categories to return.</param>
/// <param name="itemLimit">The max number of items to return per category.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <response code="200">Movie recommendations returned.</response>
/// <returns>The list of movie recommendations.</returns>
[HttpGet("Recommendations")]
public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
public async Task<ActionResult<IEnumerable<RecommendationDto>>> GetMovieRecommendations(
[FromQuery] Guid? userId,
[FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
[FromQuery] int categoryLimit = 5,
[FromQuery] int itemLimit = 8)
[FromQuery] int itemLimit = 8,
CancellationToken cancellationToken = default)
{
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
@@ -77,251 +71,16 @@ public class MoviesController : BaseJellyfinApiController
: _userManager.GetUserById(userId.Value);
var dtoOptions = new DtoOptions { Fields = fields };
var categories = new List<RecommendationDto>();
var recommendations = await _similarItemsManager
.GetMovieRecommendationsAsync(user, parentId ?? Guid.Empty, categoryLimit, itemLimit, dtoOptions, cancellationToken)
.ConfigureAwait(false);
var parentIdGuid = parentId ?? Guid.Empty;
var query = new InternalItemsQuery(user)
return Ok(recommendations.Select(r => new RecommendationDto
{
IncludeItemTypes = new[]
{
BaseItemKind.Movie,
// nameof(Trailer),
// nameof(LiveTvProgram)
},
// IsMovie = true
OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) },
Limit = 7,
ParentId = parentIdGuid,
Recursive = true,
IsPlayed = true,
DtoOptions = dtoOptions
};
var recentlyPlayedMovies = _libraryManager.GetItemList(query);
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
{
itemTypes.Add(BaseItemKind.Trailer);
itemTypes.Add(BaseItemKind.LiveTvProgram);
}
var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
{
IncludeItemTypes = itemTypes.ToArray(),
IsMovie = true,
OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) },
Limit = 10,
IsFavoriteOrLiked = true,
ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
EnableGroupByMetadataKey = true,
ParentId = parentIdGuid,
Recursive = true,
DtoOptions = dtoOptions
});
var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
// Get recently played directors
var recentDirectors = GetDirectors(mostRecentMovies)
.ToList();
// Get recently played actors
var recentActors = GetActors(mostRecentMovies)
.ToList();
var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
var categoryTypes = new List<IEnumerator<RecommendationDto>>
{
// Give this extra weight
similarToRecentlyPlayed,
similarToRecentlyPlayed,
// Give this extra weight
similarToLiked,
similarToLiked,
hasDirectorFromRecentlyPlayed,
hasActorFromRecentlyPlayed
};
while (categories.Count < categoryLimit)
{
var allEmpty = true;
foreach (var category in categoryTypes)
{
if (category.MoveNext())
{
categories.Add(category.Current);
allEmpty = false;
if (categories.Count >= categoryLimit)
{
break;
}
}
}
if (allEmpty)
{
break;
}
}
return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable());
}
private IEnumerable<RecommendationDto> GetWithDirector(
User? user,
IEnumerable<string> names,
int itemLimit,
DtoOptions dtoOptions,
RecommendationType type)
{
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
{
itemTypes.Add(BaseItemKind.Trailer);
itemTypes.Add(BaseItemKind.LiveTvProgram);
}
foreach (var name in names)
{
var items = _libraryManager.GetItemList(
new InternalItemsQuery(user)
{
Person = name,
// Account for duplicates by IMDb id, since the database doesn't support this yet
Limit = itemLimit + 2,
PersonTypes = new[] { PersonType.Director },
IncludeItemTypes = itemTypes.ToArray(),
IsMovie = true,
EnableGroupByMetadataKey = true,
DtoOptions = dtoOptions
}).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
.Take(itemLimit)
.ToList();
if (items.Count > 0)
{
var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
yield return new RecommendationDto
{
BaselineItemName = name,
CategoryId = name.GetMD5(),
RecommendationType = type,
Items = returnItems
};
}
}
}
private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
{
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
{
itemTypes.Add(BaseItemKind.Trailer);
itemTypes.Add(BaseItemKind.LiveTvProgram);
}
foreach (var name in names)
{
var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
{
Person = name,
// Account for duplicates by IMDb id, since the database doesn't support this yet
Limit = itemLimit + 2,
IncludeItemTypes = itemTypes.ToArray(),
IsMovie = true,
EnableGroupByMetadataKey = true,
DtoOptions = dtoOptions
}).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
.Take(itemLimit)
.ToList();
if (items.Count > 0)
{
var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
yield return new RecommendationDto
{
BaselineItemName = name,
CategoryId = name.GetMD5(),
RecommendationType = type,
Items = returnItems
};
}
}
}
private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
{
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
{
itemTypes.Add(BaseItemKind.Trailer);
itemTypes.Add(BaseItemKind.LiveTvProgram);
}
foreach (var item in baselineItems)
{
var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
{
Limit = itemLimit,
IncludeItemTypes = itemTypes.ToArray(),
IsMovie = true,
EnableGroupByMetadataKey = true,
DtoOptions = dtoOptions
});
if (similar.Count > 0)
{
var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
yield return new RecommendationDto
{
BaselineItemName = item.Name,
CategoryId = item.Id,
RecommendationType = type,
Items = returnItems
};
}
}
}
private IEnumerable<string> GetActors(IEnumerable<BaseItem> items)
{
var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director })
{
MaxListOrder = 3
});
var itemIds = items.Select(i => i.Id).ToList();
return people
.Where(i => itemIds.Contains(i.ItemId))
.Select(i => i.Name)
.DistinctNames();
}
private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items)
{
var people = _libraryManager.GetPeople(new InternalPeopleQuery(
new[] { PersonType.Director },
Array.Empty<string>()));
var itemIds = items.Select(i => i.Id).ToList();
return people
.Where(i => itemIds.Contains(i.ItemId))
.Select(i => i.Name)
.DistinctNames();
BaselineItemName = r.BaselineItemName,
CategoryId = r.CategoryId,
RecommendationType = r.RecommendationType,
Items = _dtoService.GetBaseItemDtos(r.Items, dtoOptions, user)
}));
}
}

View File

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

View File

@@ -145,13 +145,15 @@ public class StartupController : BaseJellyfinApiController
return BadRequest("Password must not be empty");
}
if (startupUserDto.Name is not null)
{
user.Username = startupUserDto.Name;
}
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
#pragma warning disable CA1309 // Use ordinal string comparison
if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase))
{
await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false);
}
#pragma warning restore CA1309 // Use ordinal string comparison
if (!string.IsNullOrEmpty(startupUserDto.Password))
{
await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);

View File

@@ -232,7 +232,7 @@ public class TvShowsController : BaseJellyfinApiController
if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
{
var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value);
var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value, user);
if (item is not Season seasonItem)
{
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
{
var series = _libraryManager.GetItemById<Series>(seriesId);
var series = _libraryManager.GetItemById<Series>(seriesId, user);
if (series is null)
{
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.
{
if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series)
if (_libraryManager.GetItemById<BaseItem>(seriesId, user) is not Series series)
{
return NotFound("Series not found");
}

View File

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

View File

@@ -429,14 +429,8 @@ public class UserLibraryController : BaseJellyfinApiController
}
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()
.Where(e => e.ExtraType == ExtraType.Trailer)
return Ok(item.GetExtras([ExtraType.Trailer], user)
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
}
@@ -487,7 +481,7 @@ public class UserLibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions();
return Ok(item
.GetExtras()
.GetExtras(user)
.Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value))
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
}
@@ -557,6 +551,8 @@ public class UserLibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
dtoOptions.PreferEpisodeParentPoster = true;
var list = _userViewManager.GetLatestItems(
new LatestItemsQuery
{
@@ -577,7 +573,7 @@ public class UserLibraryController : BaseJellyfinApiController
var item = tuple.Item2[0];
var childCount = 0;
if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum || tuple.Item1 is Series))
if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum))
{
item = tuple.Item1;
childCount = tuple.Item2.Count;

View File

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

View File

@@ -351,11 +351,20 @@ public class MediaInfoHelper
/// </summary>
/// <param name="result">Playback info response.</param>
/// <param name="maxBitrate">Max bitrate.</param>
public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
/// <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, Guid preferredItemId = default)
{
var originalList = result.MediaSources.ToList();
result.MediaSources = result.MediaSources.OrderBy(i =>
// The queried item's source carries the user's resume state for that version, so it must stay the
// 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
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)

View File

@@ -170,12 +170,22 @@ public sealed partial class BaseItemRepository
};
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking
// the lowest Id per group. Keep as an IQueryable sub-select so paging is applied AFTER
// the lowest Id per group. For MusicArtist, prefer the entity from a library the user
// can actually access,since the same artist can have a folder in multiple libraries.
// Keep as an IQueryable sub-select so paging is applied AFTER
// ApplyOrder runs the caller's actual sort.
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
var representativeIds = masterQuery
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g.Min(e => e.Id));
var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
var representativeIds = isMusicArtist
? masterQuery
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g
.OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
.ThenBy(e => e.Id)
.First().Id)
: masterQuery
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g.Min(e => e.Id));
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)

View File

@@ -586,8 +586,7 @@ public sealed partial class BaseItemRepository
if (filter.AlbumIds.Length > 0)
{
var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id);
baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album));
baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value));
}
if (filter.ExcludeArtistIds.Length > 0)
@@ -953,24 +952,17 @@ public sealed partial class BaseItemRepository
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
{
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)));
baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds);
}
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
{
// 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)));
}
baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId);
}
var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray();
if (includeSelected.Length > 0)
{
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)
{
baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
}
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)

View File

@@ -557,9 +557,11 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
// Deduplicate; local (file-based) relationships take priority over linked (user-merged)
// ones, matching the LinkedChildren migration.
newLinkedChildren = newLinkedChildren
.GroupBy(c => c.ChildId)
.Select(g => g.Last())
.Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
.ToList();
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();

View File

@@ -91,14 +91,25 @@ public class LinkedChildrenService : ILinkedChildrenService
}
/// <inheritdoc/>
public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId)
public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null)
{
using var context = _dbProvider.CreateDbContext();
return context.LinkedChildren
.Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual)
.Select(lc => lc.ParentId)
.Distinct()
.ToList();
var query = context.LinkedChildren
.Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual);
if (parentType.HasValue)
{
var parentTypeName = _itemTypeLookup.BaseItemKindNames[parentType.Value];
query = query.Join(
context.BaseItems
.Where(item => item.Type == parentTypeName),
lc => lc.ParentId,
item => item.Id,
(lc, _) => lc);
}
return query.Select(lc => lc.ParentId).Distinct().ToList();
}
/// <inheritdoc/>

View File

@@ -165,6 +165,42 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
transaction.Commit();
}
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
{
using var context = _dbProvider.CreateDbContext();
var query = context.PeopleBaseItemMap
.AsNoTracking()
.Where(m => itemIds.Contains(m.ItemId));
if (personTypes.Count > 0)
{
query = query.Where(m => personTypes.Contains(m.People.PersonType));
}
var rows = query
.OrderBy(m => m.ListOrder)
.Select(m => new { m.ItemId, m.People.Name })
.ToList();
var result = new Dictionary<Guid, IReadOnlyList<string>>();
foreach (var group in rows.GroupBy(r => r.ItemId))
{
var names = group
.Select(r => r.Name)
.Where(name => !string.IsNullOrEmpty(name))
.Distinct()
.ToArray();
if (names.Length > 0)
{
result[group.Key] = names;
}
}
return result;
}
private PersonInfo Map(People people)
{
var mapping = people.BaseItems?.FirstOrDefault();
@@ -239,7 +275,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty())
{
query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value);
query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value));
}
if (!string.IsNullOrWhiteSpace(filter.NameContains))

View File

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

View File

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

View File

@@ -193,84 +193,89 @@ internal class JellyfinMigrationService
{
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
var pendingCodeMigrations = migrationStage
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
.Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
.ToArray();
(string Key, IInternalMigration Migration)[] migrations = [];
(string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
{
pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
.Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
.ToArray();
}
do
{ // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply.
var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
var pendingCodeMigrations = migrationStage
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
.Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
.ToArray();
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
foreach (var item in migrations)
{
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try
(string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
{
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
.Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
.ToArray();
}
catch (Exception ex)
{
migrationLogger.LogCritical("Error: {Error}", ex.Message);
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
foreach (var item in migrations)
{
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try
{
if (_backupKey.LibraryDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback librarydb.");
try
{
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
}
}
if (_backupKey.JellyfinDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
try
{
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
}
}
if (_backupKey.FullBackup is not null)
{
migrationLogger.LogInformation("Attempt to rollback from backup.");
try
{
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
}
}
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
}
catch (Exception ex)
{
migrationLogger.LogCritical("Error: {Error}", ex.Message);
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
throw;
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
{
if (_backupKey.LibraryDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback librarydb.");
try
{
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
}
}
if (_backupKey.JellyfinDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
try
{
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
}
}
if (_backupKey.FullBackup is not null)
{
migrationLogger.LogInformation("Attempt to rollback from backup.");
try
{
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
}
}
}
throw;
}
}
}
} while (migrations.Length != 0);
}
}

View File

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

View File

@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Part 2 Migration for NormalisedUsername.
/// </summary>
[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
#pragma warning disable SA1649 // File name should match first type name
public class UpdateNormalizedUsername : IAsyncMigrationRoutine
#pragma warning restore SA1649 // File name should match first type name
{
private readonly IDbContextFactory<JellyfinDbContext> _contextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class.
/// </summary>
/// <param name="contextFactory">Db Context factory.</param>
public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var user in users)
{
user.NormalizedUsername = user.Username.ToUpperInvariant();
}
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
}

View File

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

View File

@@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to disable legacy authorization in the system config.
/// </summary>
[JellyfinMigration("2026-05-31T16:00:00", nameof(DisableLegacyAuthorization), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
public class DisableLegacyAuthorization : IAsyncMigrationRoutine
{
private readonly IServerConfigurationManager _serverConfigurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager)
{
_serverConfigurationManager = serverConfigurationManager;
}
/// <inheritdoc />
public Task PerformAsync(CancellationToken cancellationToken)
{
_serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
_serverConfigurationManager.SaveConfiguration();
return Task.CompletedTask;
}
}

View File

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

View File

@@ -57,6 +57,14 @@ namespace MediaBrowser.Controller.Collections
/// <returns>IEnumerable{BaseItem}.</returns>
IEnumerable<BaseItem> CollapseItemsWithinBoxSets(IEnumerable<BaseItem> items, User user);
/// <summary>
/// Gets the collections accessible to the supplied user that contain the provided item.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="itemId">The item identifier.</param>
/// <returns>The collections containing the item.</returns>
IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId);
/// <summary>
/// Gets the folder where collections are stored.
/// </summary>

View File

@@ -1,5 +1,3 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Linq;
@@ -8,13 +6,16 @@ using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Dto
{
/// <summary>
/// Options that control which fields and images are populated when building a <see cref="MediaBrowser.Model.Dto.BaseItemDto"/>.
/// </summary>
public class DtoOptions
{
private static readonly ItemFields[] DefaultExcludedFields = new[]
{
private static readonly ItemFields[] DefaultExcludedFields =
[
ItemFields.SeasonUserData,
ItemFields.RefreshState
};
];
private static readonly ImageType[] AllImageTypes = Enum.GetValues<ImageType>();
@@ -22,11 +23,18 @@ namespace MediaBrowser.Controller.Dto
.Except(DefaultExcludedFields)
.ToArray();
/// <summary>
/// Initializes a new instance of the <see cref="DtoOptions"/> class with all fields enabled.
/// </summary>
public DtoOptions()
: this(true)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DtoOptions"/> class.
/// </summary>
/// <param name="allFields">Whether to populate all available fields.</param>
public DtoOptions(bool allFields)
{
ImageTypeLimit = int.MaxValue;
@@ -38,23 +46,61 @@ namespace MediaBrowser.Controller.Dto
ImageTypes = AllImageTypes;
}
/// <summary>
/// Gets or sets the fields to populate on the DTO.
/// </summary>
public IReadOnlyList<ItemFields> Fields { get; set; }
/// <summary>
/// Gets or sets the image types to populate on the DTO.
/// </summary>
public IReadOnlyList<ImageType> ImageTypes { get; set; }
/// <summary>
/// Gets or sets the maximum number of images to return per image type.
/// </summary>
public int ImageTypeLimit { get; set; }
/// <summary>
/// Gets or sets a value indicating whether image information is populated.
/// </summary>
public bool EnableImages { get; set; }
/// <summary>
/// Gets or sets a value indicating whether program recording information is populated.
/// </summary>
public bool AddProgramRecordingInfo { get; set; }
/// <summary>
/// Gets or sets a value indicating whether user data is populated.
/// </summary>
public bool EnableUserData { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the currently airing program is populated.
/// </summary>
public bool AddCurrentProgram { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an episode's portrait poster (its season's primary
/// image, falling back to the series') should replace the episode's own (16:9) primary image.
/// Used by views that render episodes as poster cards, e.g. "Latest".
/// </summary>
public bool PreferEpisodeParentPoster { get; set; }
/// <summary>
/// Gets a value indicating whether the specified field is populated.
/// </summary>
/// <param name="field">The field to check.</param>
/// <returns><c>true</c> if the field is populated; otherwise, <c>false</c>.</returns>
public bool ContainsField(ItemFields field)
=> Fields.Contains(field);
/// <summary>
/// Gets the number of images to return for the specified image type.
/// </summary>
/// <param name="type">The image type.</param>
/// <returns>The image limit for the type, or 0 if the type is not enabled.</returns>
public int GetImageLimit(ImageType type)
{
if (EnableImages && ImageTypes.Contains(type))

View File

@@ -23,7 +23,6 @@ using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
@@ -94,6 +93,8 @@ namespace MediaBrowser.Controller.Entities
private string _name;
private string _originalLanguage;
public const char SlugChar = '-';
protected BaseItem()
@@ -217,7 +218,11 @@ namespace MediaBrowser.Controller.Entities
public string OriginalTitle { get; set; }
[JsonIgnore]
public string OriginalLanguage { get; set; }
public string OriginalLanguage
{
get => _originalLanguage;
set => _originalLanguage = LocalizationManager?.FindLanguageInfo(value)?.TwoLetterISOLanguageName ?? value;
}
/// <summary>
/// Gets or sets the id.
@@ -1128,15 +1133,7 @@ namespace MediaBrowser.Controller.Entities
ArgumentNullException.ThrowIfNull(item);
var protocol = item.PathProtocol;
// Resolve the item path so everywhere we use the media source it will always point to
// the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link
// path will return null, so it's safe to check for all paths.
var itemPath = item.Path;
if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo)
{
itemPath = linkInfo.FullName;
}
var info = new MediaSourceInfo
{
@@ -1564,7 +1561,7 @@ namespace MediaBrowser.Controller.Entities
}
/// <summary>
/// Gets the preferred metadata language.
/// Gets the preferred metadata country code.
/// </summary>
/// <returns>System.String.</returns>
public string GetPreferredMetadataCountryCode()
@@ -1598,6 +1595,15 @@ namespace MediaBrowser.Controller.Entities
return lang;
}
/// <summary>
/// Gets the original language of the item, inheriting from parent items if necessary.
/// </summary>
/// <returns>System.String.</returns>
public virtual string GetInheritedOriginalLanguage()
{
return OriginalLanguage;
}
public virtual bool IsSaveLocalMetadataEnabled()
{
if (SourceType == SourceType.Channel)
@@ -2712,7 +2718,7 @@ namespace MediaBrowser.Controller.Entities
public IReadOnlyList<BaseItem> GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
{
return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray();
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray();
}
public IReadOnlyList<BaseItem> GetThemeVideos(User user = null)
@@ -2722,16 +2728,17 @@ namespace MediaBrowser.Controller.Entities
public IReadOnlyList<BaseItem> GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
{
return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
}
/// <summary>
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
/// </summary>
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
/// <returns>An enumerable containing the items.</returns>
public IEnumerable<BaseItem> GetExtras()
public IEnumerable<BaseItem> GetExtras(User user = null)
{
return LibraryManager.GetItemList(new InternalItemsQuery()
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
OwnerIds = [Id],
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
@@ -2742,10 +2749,11 @@ namespace MediaBrowser.Controller.Entities
/// Get all extras with specific types that are associated with this item.
/// </summary>
/// <param name="extraTypes">The types of extras to retrieve.</param>
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
/// <returns>An enumerable containing the extras.</returns>
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes)
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes, User user = null)
{
return LibraryManager.GetItemList(new InternalItemsQuery()
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
OwnerIds = [Id],
ExtraTypes = extraTypes.ToArray(),

View File

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

View File

@@ -153,6 +153,12 @@ namespace MediaBrowser.Controller.Entities.TV
return 16.0 / 9;
}
/// <inheritdoc />
public override string GetInheritedOriginalLanguage()
{
return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage();
}
public override List<string> GetUserDataKeys()
{
var list = base.GetUserDataKeys();

View File

@@ -128,6 +128,12 @@ namespace MediaBrowser.Controller.Entities.TV
return result;
}
/// <inheritdoc />
public override string GetInheritedOriginalLanguage()
{
return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage();
}
public override string CreatePresentationUniqueKey()
{
if (IndexNumber.HasValue)

View File

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

View File

@@ -10,6 +10,7 @@ using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
@@ -278,6 +279,17 @@ namespace MediaBrowser.Controller.Entities
return linkedVersionCount + localVersionCount + 1;
}
/// <inheritdoc />
public override string GetInheritedOriginalLanguage()
{
if (ExtraType.GetValueOrDefault() == Model.Entities.ExtraType.Trailer)
{
return GetOwner()?.GetInheritedOriginalLanguage();
}
return OriginalLanguage ?? GetOwner()?.GetInheritedOriginalLanguage();
}
public override List<string> GetUserDataKeys()
{
var list = base.GetUserDataKeys();
@@ -379,13 +391,13 @@ namespace MediaBrowser.Controller.Entities
/// <summary>
/// Gets the additional parts.
/// </summary>
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
/// <returns>IEnumerable{Video}.</returns>
public IOrderedEnumerable<Video> GetAdditionalParts()
public IOrderedEnumerable<Video> GetAdditionalParts(User user = null)
{
return GetAdditionalPartIds()
.Select(i => LibraryManager.GetItemById(i))
.Select(i => LibraryManager.GetItemById<Video>(i, user))
.Where(i => i is not null)
.OfType<Video>()
.OrderBy(i => i.SortName);
}

View File

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

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Library;
/// <summary>
/// A local similar items provider that supports batch queries across multiple source items.
/// Implementations share access filtering and entity loading across all sources for better performance.
/// </summary>
public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider
{
/// <summary>
/// Gets similar items for multiple source items in a single batch.
/// </summary>
/// <param name="sourceItems">The source items to find similar items for.</param>
/// <param name="query">The query options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-source-item results keyed by source item ID.</returns>
Task<Dictionary<Guid, IReadOnlyList<BaseItem>>> GetBatchSimilarItemsAsync(
IReadOnlyList<BaseItem> sourceItems,
SimilarItemsQuery query,
CancellationToken cancellationToken);
}

View File

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

View File

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

View File

@@ -597,6 +597,14 @@ namespace MediaBrowser.Controller.Library
/// <returns>List&lt;System.String&gt;.</returns>
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query);
/// <summary>
/// Gets the distinct people names per item for multiple items.
/// </summary>
/// <param name="itemIds">The item IDs.</param>
/// <param name="personTypes">The person types to include.</param>
/// <returns>A dictionary mapping each item ID to its distinct people names. Items with no matching people are omitted.</returns>
IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes);
/// <summary>
/// Queries the items.
/// </summary>

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.Library;
@@ -47,4 +48,23 @@ public interface ISimilarItemsManager
int? limit,
LibraryOptions? libraryOptions,
CancellationToken cancellationToken);
/// <summary>
/// Builds movie recommendations for a user: a mix of similar-items and person-based categories,
/// scheduled round-robin and capped to <paramref name="categoryLimit"/>.
/// </summary>
/// <param name="user">The user the recommendations are for. May be <see langword="null"/> for anonymous access.</param>
/// <param name="parentId">The library/folder to localize the search to. Pass <see cref="Guid.Empty"/> to use the root.</param>
/// <param name="categoryLimit">Maximum number of recommendation categories to return.</param>
/// <param name="itemLimit">Maximum number of items per category.</param>
/// <param name="dtoOptions">DTO options used when querying the library.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The list of recommendation categories, ordered by <see cref="RecommendationType"/>.</returns>
Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
User? user,
Guid parentId,
int categoryLimit,
int itemLimit,
DtoOptions dtoOptions,
CancellationToken cancellationToken);
}

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.Library;
/// <summary>
/// A recommendation category derived from a baseline item, holding similar items prior to DTO conversion.
/// </summary>
public sealed class SimilarItemsRecommendation
{
/// <summary>
/// Gets the display name of the baseline item the recommendation is based on.
/// </summary>
public required string BaselineItemName { get; init; }
/// <summary>
/// Gets an identifier for the recommendation category.
/// </summary>
public required Guid CategoryId { get; init; }
/// <summary>
/// Gets the recommendation type.
/// </summary>
public required RecommendationType RecommendationType { get; init; }
/// <summary>
/// Gets the similar items for the baseline, ordered by relevance.
/// </summary>
public required IReadOnlyList<BaseItem> Items { get; init; }
}

View File

@@ -86,6 +86,7 @@ namespace MediaBrowser.Controller.MediaEncoding
private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0);
private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1);
private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0);
private readonly Version _minFFmpegNoiseBsfDrop = new Version(5, 0);
private static readonly string[] _videoProfilesH264 =
[
@@ -443,6 +444,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
}
private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
{
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
return doDeintH264 || doDeintHevc;
}
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
{
var videoStream = state.VideoStream;
@@ -1547,20 +1555,61 @@ namespace MediaBrowser.Controller.MediaEncoding
public string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer)
{
var bitStreamArgs = string.Empty;
var filters = new List<string>();
var noiseFilter = GetCopiedAudioTrimBsf(state);
if (!string.IsNullOrEmpty(noiseFilter))
{
filters.Add(noiseFilter);
}
var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.');
// Apply aac_adtstoasc bitstream filter when media source is in mpegts.
if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(mediaSourceContainer, "ts", StringComparison.OrdinalIgnoreCase)
|| string.Equals(mediaSourceContainer, "aac", StringComparison.OrdinalIgnoreCase)
|| string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)))
|| string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase))
&& IsAAC(state.AudioStream))
{
bitStreamArgs = GetBitStreamArgs(state, MediaStreamType.Audio);
bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs;
filters.Add("aac_adtstoasc");
}
return bitStreamArgs;
return filters.Count == 0
? string.Empty
: " -bsf:a " + string.Join(',', filters);
}
// When video is transcoded, accurate_seek (the default) trims video to the
// exact seek point via decoder-side frame discard. But stream-copied audio
// bypasses the decoder, so it starts from the nearest keyframe — potentially
// seconds before the target. Use the noise bsf to drop copied audio packets
// before the seek target, achieving the same trim precision without
// re-encoding. The noise bsf's drop= parameter requires ffmpeg >= 5.0.
// Important: make sure not to use it with wtv because it breaks seeking
private string GetCopiedAudioTrimBsf(EncodingJobInfo state)
{
if (state.TranscodingType is not TranscodingJobType.Hls
|| !state.IsVideoRequest
|| IsCopyCodec(state.OutputVideoCodec)
|| !IsCopyCodec(state.OutputAudioCodec)
|| string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase)
|| _mediaEncoder.EncoderVersion < _minFFmpegNoiseBsfDrop)
{
return null;
}
var startTicks = state.BaseRequest.StartTimeTicks ?? 0;
if (startTicks <= 0)
{
return null;
}
var seekSeconds = startTicks / (double)TimeSpan.TicksPerSecond;
return string.Format(
CultureInfo.InvariantCulture,
"noise=drop='lt(pts*tb\\,{0:F3})'",
seekSeconds);
}
public static string GetSegmentFileExtension(string segmentContainer)
@@ -2014,11 +2063,15 @@ namespace MediaBrowser.Controller.MediaEncoding
args += keyFrameArg + gopArg;
}
// global_header produced by AMD HEVC VA-API encoder causes non-playable fMP4 on iOS
// The in-band Parameter Sets generated by the AMD HEVC VA-API encoder is inconsistent
// with the extradata generated by ffmpeg, causing decoding failures when using hvc1.
if (string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
&& _mediaEncoder.IsVaapiDeviceAmd)
{
args += " -flags:v -global_header";
// Extracting the extradata from the in-band PS to bypass the issue.
// This can be removed once the issue is resolved in libva or Mesa.
// Transcoding is unavoidable here, so using BSF will not conflict with BSF in remuxing.
args += " -flags:v -global_header -bsf:v extract_extradata=remove=0";
}
return args;
@@ -3002,23 +3055,6 @@ namespace MediaBrowser.Controller.MediaEncoding
}
seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(seekTick));
if (state.IsVideoRequest)
{
// If we are remuxing, then the copied stream cannot be seeked accurately (it will seek to the nearest
// keyframe). If we are using fMP4, then force all other streams to use the same inaccurate seeking to
// avoid A/V sync issues which cause playback issues on some devices.
// When remuxing video, the segment start times correspond to key frames in the source stream, so this
// option shouldn't change the seeked point that much.
// Important: make sure not to use it with wtv because it breaks seeking
if (state.TranscodingType is TranscodingJobType.Hls
&& string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase)
&& (IsCopyCodec(state.OutputVideoCodec) || IsCopyCodec(state.OutputAudioCodec))
&& !string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase))
{
seekParam += " -noaccurate_seek";
}
}
}
return seekParam;
@@ -3821,9 +3857,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase);
var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var doToneMap = IsSwTonemapAvailable(state, options);
var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI;
@@ -3975,9 +4009,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isCuInCuOut = isNvDecoder && isNvencEncoder;
var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var doCuTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4186,9 +4218,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4434,9 +4464,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var doVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVppTonemap || doOclTonemap;
@@ -4728,12 +4756,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap;
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5059,12 +5085,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isVaInVaOut = isVaapiDecoder && isVaapiEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap;
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5296,10 +5320,8 @@ namespace MediaBrowser.Controller.MediaEncoding
var isSwEncoder = !isVaapiEncoder;
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVkTonemap = IsVulkanHwTonemapAvailable(state, options);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5536,9 +5558,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965;
var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -5769,9 +5789,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var reqMaxH = state.BaseRequest.MaxHeight;
var threeDFormat = state.MediaSource.Video3DFormat;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options);
var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options);
var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface);
@@ -5970,9 +5988,7 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase)
|| vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase));
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -6236,12 +6252,21 @@ namespace MediaBrowser.Controller.MediaEncoding
overlayFilters?.RemoveAll(string.IsNullOrEmpty);
var framerate = GetFramerateParam(state);
if (framerate.HasValue)
if (mainFilters is not null && framerate.HasValue)
{
mainFilters.Insert(0, string.Format(
CultureInfo.InvariantCulture,
"fps={0}",
framerate.Value));
var doDeintH2645 = IsDeinterlaceAvailable(state);
var fpsFilter = string.Format(CultureInfo.InvariantCulture, "fps={0}", framerate.Value);
// For filter chain containing the deinterlace filter,
// place the fps filter at the end to preserve temporal info.
if (doDeintH2645)
{
mainFilters.Add(fpsFilter);
}
else
{
mainFilters.Insert(0, fpsFilter);
}
}
var mainStr = string.Empty;

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.Audio;
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
@@ -29,8 +30,9 @@ public interface ILinkedChildrenService
/// Gets parent IDs that reference the specified child with LinkedChildType.Manual.
/// </summary>
/// <param name="childId">The child item ID.</param>
/// <param name="parentType">Optional parent item type filter.</param>
/// <returns>List of parent IDs that reference the child.</returns>
IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId);
IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null);
/// <summary>
/// Updates LinkedChildren references from one child to another.

View File

@@ -32,4 +32,12 @@ public interface IPeopleRepository
/// <param name="filter">The query.</param>
/// <returns>The list of people names matching the filter.</returns>
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter);
/// <summary>
/// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table.
/// </summary>
/// <param name="itemIds">The item IDs to get people for.</param>
/// <param name="personTypes">The person types to include (e.g. "Actor", "Director").</param>
/// <returns>A dictionary mapping each item ID to its distinct people names, ordered by cast list order. Items with no matching people are omitted.</returns>
IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes);
}

View File

@@ -45,7 +45,6 @@ namespace MediaBrowser.Controller.Session
PlayState = new PlayerStateInfo();
SessionControllers = [];
NowPlayingQueue = [];
NowPlayingQueueFullItems = [];
}
/// <summary>
@@ -271,16 +270,10 @@ namespace MediaBrowser.Controller.Session
/// <value>The now playing queue.</value>
public IReadOnlyList<QueueItem> NowPlayingQueue { get; set; }
/// <summary>
/// Gets or sets the now playing queue full items.
/// </summary>
/// <value>The now playing queue full items.</value>
public IReadOnlyList<BaseItemDto> NowPlayingQueueFullItems { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the session has a custom device name.
/// </summary>
/// <value><c>true</c> if this session has a custom device name; otherwise, <c>false</c>.</value>
/// <value><c>true</c> if the session has a custom device name; otherwise, <c>false</c>.</value>
public bool HasCustomDeviceName { get; set; }
/// <summary>

View File

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

View File

@@ -76,7 +76,13 @@ namespace MediaBrowser.MediaEncoding.Probing
/// <returns>Dictionary{System.StringSystem.String}.</returns>
private static Dictionary<string, string?> ConvertDictionaryToCaseInsensitive(IReadOnlyDictionary<string, string?> dict)
{
return new Dictionary<string, string?>(dict, StringComparer.OrdinalIgnoreCase);
var result = new Dictionary<string, string?>(dict.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in dict)
{
result.TryAdd(key, value);
}
return result;
}
}
}

View File

@@ -1,57 +0,0 @@
using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using MediaBrowser.Model.MediaInfo;
namespace MediaBrowser.MediaEncoding.Subtitles
{
/// <summary>
/// ASS subtitle writer.
/// </summary>
public partial class AssWriter : ISubtitleWriter
{
[GeneratedRegex(@"\n", RegexOptions.IgnoreCase)]
private static partial Regex NewLineRegex();
/// <inheritdoc />
public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
{
using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
{
var trackEvents = info.TrackEvents;
var timeFormat = @"hh\:mm\:ss\.ff";
// Write ASS header
writer.WriteLine("[Script Info]");
writer.WriteLine("Title: Jellyfin transcoded ASS subtitle");
writer.WriteLine("ScriptType: v4.00+");
writer.WriteLine();
writer.WriteLine("[V4+ Styles]");
writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding");
writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H910E0807,0,0,0,0,100,100,0,0,0,1,0,2,10,10,10,1");
writer.WriteLine();
writer.WriteLine("[Events]");
writer.WriteLine("Format: Layer, Start, End, Style, Text");
for (int i = 0; i < trackEvents.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var trackEvent = trackEvents[i];
var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
var text = NewLineRegex().Replace(trackEvent.Text, "\\n");
writer.WriteLine(
"Dialogue: 0,{0},{1},Default,{2}",
startTime,
endTime,
text);
}
}
}
}
}

View File

@@ -1,20 +0,0 @@
using System.IO;
using System.Threading;
using MediaBrowser.Model.MediaInfo;
namespace MediaBrowser.MediaEncoding.Subtitles
{
/// <summary>
/// Interface ISubtitleWriter.
/// </summary>
public interface ISubtitleWriter
{
/// <summary>
/// Writes the specified information.
/// </summary>
/// <param name="info">The information.</param>
/// <param name="stream">The stream.</param>
/// <param name="cancellationToken">The cancellation token.</param>
void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken);
}
}

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